From 59a4a6d08f2486c10a3947b697004541e56d9822 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 17:06:40 -0500 Subject: [PATCH 001/180] chore(repo): add gitignore and gitattributes --- .gitattributes | 1 + .gitignore | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..905cbe0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# AI coding agents (paths derived from the canopy registry) +.agents/* +.blackboxcli/* +.claude/* +.cline/* +.clineignore +.codebuddy/* +.codex/* +.commandcode/* +.continue/* +.copilot/* +.cursor/* +.cursorignore +.cursorindexingignore +.devin/* +.gemini/* +.kilo/* +.kilocode/* +.kiro/* +.mcp.json +.mimocode/* +.opencode/* +.qoder/* +.qwen/* +.utim/* +.utim_tmp/* +.vibe/* +CLAUDE.local.md +kilo.json +kilo.jsonc +mimocode.json +mimocode.jsonc +opencode.json +opencode.jsonc +skills-lock.json +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +pids +*.pid +*.seed +*.pid.lock +lib-cov +coverage +*.lcov +.nyc_output +.grunt +bower_components +.lock-wscript +build/Release +node_modules/ +jspm_packages/ +web_modules/ +*.tsbuildinfo +.npm +.eslintcache +.stylelintcache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ +.node_repl_history +*.tgz +.yarn-integrity +.env +.env.development.local +.env.test.local +.env.production.local +.env.local +.cache +.parcel-cache +.next +out +.nuxt +dist +.cache/ +.vuepress/dist +.temp +.docusaurus +.serverless/ +.fusebox/ +.dynamodb/ +.tern-port +.vscode-test +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +.webpack/ +.svelte-kit +# kept from a previous agentic block +*.asp +*.cer +*.csr +*.css +*.htm +*.html +*.js +*.jsp +*.php +*.rss +*.wasm +*.wat +*.xhtml + +# instruction files stay committable +!.claude/CLAUDE.md +!.continue/rules/ +!.cursor/rules/ + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.Spotlight-V100 +.Trashes + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.lnk + +# Cloudflare Wrangler +.wrangler/ +.dev.vars +.dev.vars.* From 28d08b7ee6fe06e655c3b7e654666e73cc91b6ba Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 17:28:19 -0500 Subject: [PATCH 002/180] feat(schema): define the graph and node document schemas with a validate command and CI on pull requests --- .github/workflows/ci.yml | 63 + README.md | 52 + package-lock.json | 2765 +++++++++++++++++ package.json | 27 + registry/graphs/hello-graph/README.md | 5 + registry/graphs/hello-graph/graph.json | 85 + registry/nodes/hello-node/README.md | 5 + registry/nodes/hello-node/node.json | 16 + schema/graph.schema.json | 205 ++ schema/node.schema.json | 28 + src/schema.ts | 26 + src/slug.ts | 21 + src/validate.ts | 243 ++ tests/fixtures/bad-slug-node/README.md | 3 + tests/fixtures/bad-slug-node/node.json | 16 + .../fixtures/missing-readme-graph/graph.json | 85 + tests/fixtures/name-mismatch-node/README.md | 3 + tests/fixtures/name-mismatch-node/node.json | 16 + tests/fixtures/nested-platform-node/README.md | 3 + tests/fixtures/nested-platform-node/node.json | 20 + tests/fixtures/unknown-field-graph/README.md | 3 + tests/fixtures/unknown-field-graph/graph.json | 129 + tests/fixtures/valid-graph/README.md | 5 + tests/fixtures/valid-graph/graph.json | 85 + tests/fixtures/valid-node/README.md | 5 + tests/fixtures/valid-node/node.json | 16 + tests/validate.test.ts | 60 + tsconfig.json | 17 + vitest.config.ts | 8 + worker/README.md | 7 + 30 files changed, 4022 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 registry/graphs/hello-graph/README.md create mode 100644 registry/graphs/hello-graph/graph.json create mode 100644 registry/nodes/hello-node/README.md create mode 100644 registry/nodes/hello-node/node.json create mode 100644 schema/graph.schema.json create mode 100644 schema/node.schema.json create mode 100644 src/schema.ts create mode 100644 src/slug.ts create mode 100644 src/validate.ts create mode 100644 tests/fixtures/bad-slug-node/README.md create mode 100644 tests/fixtures/bad-slug-node/node.json create mode 100644 tests/fixtures/missing-readme-graph/graph.json create mode 100644 tests/fixtures/name-mismatch-node/README.md create mode 100644 tests/fixtures/name-mismatch-node/node.json create mode 100644 tests/fixtures/nested-platform-node/README.md create mode 100644 tests/fixtures/nested-platform-node/node.json create mode 100644 tests/fixtures/unknown-field-graph/README.md create mode 100644 tests/fixtures/unknown-field-graph/graph.json create mode 100644 tests/fixtures/valid-graph/README.md create mode 100644 tests/fixtures/valid-graph/graph.json create mode 100644 tests/fixtures/valid-node/README.md create mode 100644 tests/fixtures/valid-node/node.json create mode 100644 tests/validate.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 worker/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..946e5e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: ci + +on: + pull_request: + +jobs: + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + + lint: + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + typecheck: + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + + test: + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + + validate: + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run validate diff --git a/README.md b/README.md new file mode 100644 index 0000000..127aed6 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# graphs.univerlab.org catalogue + +Shareable Canopy documents live here. Two kinds: + +- **Graphs** — a graph's portable design (`GraphExportDocument`, + format version 6): name, nodes, edges, ensembles, hooks, infra node, + bundled presets. Published under `graphs//graph.json`. +- **Nodes** — a single reusable node or ensemble (`format_version: 1`): + name, kind (`agent` | `check` | `gate` | `ensemble`), description, + config, optional preset. Published under `nodes//node.json`. + +## Document layout + +```text +graphs/ + schema/ + graph.schema.json # the graph contract (v6, bundled presets) + node.schema.json # the node contract (v1) + src/ + schema.ts # imports the schemas, never copies them + slug.ts # slug rule helpers + validate.ts # `npm run validate` (CLI + library) + registry/ + graphs//graph.json + README.md + nodes//node.json + README.md + worker/ # Cloudflare Worker serving the catalogue + tests/ # vitest suite + fixtures +``` + +Every document folder must contain a `README.md`, and the folder name +must equal the document's slug-safe `name` (`^[a-z0-9-]{1,48}$`). + +## How to publish + +1. Put the document at `graphs//graph.json` (graphs) or + `nodes//node.json` (nodes), where `` matches the + document's `name`. +2. Add a `README.md` beside it describing what the document does. +3. Add the `canopy-graph` topic to the GitHub repository. +4. Run `npm run validate` — it must pass. + +## Commands + +| Script | What it does | +| ----------------- | ----------------------------------------------- | +| `npm run lint` | eslint over the repo | +| `npm run typecheck` | `tsc --noEmit` (strict) | +| `npm test` | vitest suite | +| `npm run validate [paths...]` | validate registry documents (files or folders; no args = all of `registry/`) | + +The schemas in `schema/` are the single contract the worker and +Canopy's `graph validate` agree on — import them, never copy them. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d880f3b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2765 @@ +{ + "name": "graphs-catalogue", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "graphs-catalogue", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.0", + "eslint": "^9.18.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a9a592 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "graphs-catalogue", + "version": "0.1.0", + "description": "UniverLab graphs catalogue: JSON Schemas and validator for shareable Canopy graph and node documents", + "type": "module", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "validate": "tsx src/validate.ts" + }, + "dependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.0", + "eslint": "^9.18.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/registry/graphs/hello-graph/README.md b/registry/graphs/hello-graph/README.md new file mode 100644 index 0000000..809dd2d --- /dev/null +++ b/registry/graphs/hello-graph/README.md @@ -0,0 +1,5 @@ +# hello-graph + +Example catalogue graph: an implementer agent, a triage router, a test +check, and a gate, plus a parallel reviewer ensemble and a completion +hook. diff --git a/registry/graphs/hello-graph/graph.json b/registry/graphs/hello-graph/graph.json new file mode 100644 index 0000000..0ba7a5b --- /dev/null +++ b/registry/graphs/hello-graph/graph.json @@ -0,0 +1,85 @@ +{ + "format_version": 6, + "name": "hello-graph", + "description": "Minimal example graph: implement, check, gate, and a router with two routes.", + "nodes": [ + { + "name": "implementer", + "kind": "agent", + "position": 1, + "config": { + "prompt_template": "Implement the spec." + } + }, + { + "name": "triage", + "kind": "router", + "position": 2, + "config": { + "routes": [ + { "label": "escalate", "description": "Needs a human." }, + { "label": "proceed", "description": "Safe to continue." } + ], + "fallback": "escalate" + } + }, + { + "name": "tests", + "kind": "check", + "position": 3, + "config": { + "command": "npm test" + } + }, + { + "name": "gate", + "kind": "gate", + "position": 4, + "config": { + "output_contains": "passing" + } + } + ], + "edges": [ + { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, + { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "tests", "to_node": "gate", "condition": "always" }, + { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, + { "from_node": "implementer", "to_node": "tests", "condition": "error" } + ], + "ensembles": [ + { + "name": "reviewers", + "kind": "parallel", + "commit_rights": false, + "prompt_template": "Review the diff for defects.", + "entry_from_node": "gate", + "entry_condition": "pass", + "extra_entry_sources": [], + "on_pass_to": "tests", + "min_pass": 1, + "timeout_minutes": 30, + "members": [ + { "prompt_override": "Review for correctness." }, + { "prompt_override": "Review for style.", "timeout_minutes": 10 } + ] + } + ], + "infra_node": "tests", + "hooks": { + "on_completed": [ + { "command": "echo done" } + ], + "on_failed": [], + "on_blocked": [], + "on_spec_completed": [] + }, + "presets": { + "quick": { + "content": { + "timeout_minutes": 10 + } + } + } +} diff --git a/registry/nodes/hello-node/README.md b/registry/nodes/hello-node/README.md new file mode 100644 index 0000000..2601eb5 --- /dev/null +++ b/registry/nodes/hello-node/README.md @@ -0,0 +1,5 @@ +# hello-node + +Example catalogue node: an agent that drafts an implementation plan. +Carries no platform, model, or cli bindings — the consumer supplies +those at use time. diff --git a/registry/nodes/hello-node/node.json b/registry/nodes/hello-node/node.json new file mode 100644 index 0000000..0d85553 --- /dev/null +++ b/registry/nodes/hello-node/node.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "name": "hello-node", + "kind": "agent", + "description": "Example catalogue node: drafts an implementation plan.", + "config": { + "prompt_template": "Draft a plan for {{spec_name}}.", + "timeout_minutes": 15 + }, + "preset": { + "name": "quick", + "content": { + "timeout_minutes": 5 + } + } +} diff --git a/schema/graph.schema.json b/schema/graph.schema.json new file mode 100644 index 0000000..6a0f321 --- /dev/null +++ b/schema/graph.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graphs.univerlab.org/schema/graph.schema.json", + "title": "Canopy GraphExportDocument", + "description": "Portable design of one Canopy graph (format_version 6, with bundled presets). Derived field by field from harness-canopy src/domain/graph_transfer/mod.rs; every Rust type with deny_unknown_fields sets additionalProperties false here.", + "type": "object", + "required": ["format_version", "name", "nodes", "edges"], + "properties": { + "format_version": { + "description": "Accepted 1-6 on import; writers emit 6.", + "enum": [1, 2, 3, 4, 5, 6] + }, + "name": { "$ref": "#/definitions/slug" }, + "description": { "type": "string" }, + "nodes": { + "type": "array", + "items": { "$ref": "#/definitions/node" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/definitions/edge" } + }, + "ensembles": { + "type": "array", + "items": { "$ref": "#/definitions/ensemble" }, + "default": [] + }, + "infra_node": { + "description": "Pre-wired target for infrastructure failures, by node name; null when the graph has no infra node.", + "anyOf": [{ "type": "string" }, { "type": "null" }] + }, + "hooks": { "$ref": "#/definitions/hooks" }, + "presets": { + "description": "Bundled presets shipped with the document (new in format version 6).", + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["content"], + "properties": { + "name": { "type": "string" }, + "content": true + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "definitions": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]{1,48}$" + }, + "condition": { + "description": "Routing condition: pass, fail, always, error (break reads as error), or a router label {condition: route, route}.", + "anyOf": [ + { "enum": ["pass", "fail", "always", "error", "break"] }, + { + "type": "object", + "required": ["condition", "route"], + "properties": { + "condition": { "const": "route" }, + "route": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + ] + }, + "target": { + "description": "An ensemble wiring target: a plain node name, or {ensemble} for another ensemble's quorum.", + "anyOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["ensemble"], + "properties": { + "ensemble": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + ] + }, + "node": { + "type": "object", + "required": ["name", "kind", "position", "config"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["agent", "check", "gate", "router"] }, + "position": { "type": "integer" }, + "config": { "type": "object" } + }, + "additionalProperties": false + }, + "edge": { + "type": "object", + "required": ["from_node", "to_node", "condition"], + "properties": { + "from_node": { "type": "string", "minLength": 1 }, + "to_node": { "type": "string", "minLength": 1 }, + "condition": { "$ref": "#/definitions/condition" } + }, + "additionalProperties": false + }, + "ensembleMember": { + "type": "object", + "properties": { + "platform": { "type": "string" }, + "model": { "type": "string" }, + "prompt_override": { "type": "string" }, + "timeout_minutes": { "type": "integer" } + }, + "additionalProperties": false + }, + "entrySource": { + "type": "object", + "required": ["from_node", "condition"], + "properties": { + "from_node": { "type": "string", "minLength": 1 }, + "condition": { "$ref": "#/definitions/condition" } + }, + "additionalProperties": false + }, + "ensemble": { + "type": "object", + "required": [ + "name", + "commit_rights", + "prompt_template", + "entry_from_node", + "entry_condition", + "on_pass_to", + "min_pass", + "timeout_minutes" + ], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["parallel", "cascade", "round_robin"] }, + "commit_rights": { "type": "boolean" }, + "prompt_template": { "type": "string" }, + "entry_from_node": { "$ref": "#/definitions/target" }, + "entry_condition": { "$ref": "#/definitions/condition" }, + "extra_entry_sources": { + "type": "array", + "items": { "$ref": "#/definitions/entrySource" }, + "default": [] + }, + "on_pass_to": { "$ref": "#/definitions/target" }, + "on_fail_to": { "$ref": "#/definitions/target" }, + "min_pass": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "straggler_timeout_minutes": { "type": "integer" }, + "quorum_grace_minutes": { "type": "integer" }, + "crew": { "type": "string" }, + "members": { + "type": "array", + "items": { "$ref": "#/definitions/ensembleMember" }, + "default": [] + } + }, + "additionalProperties": false + }, + "hook": { + "description": "One graph completion hook: agent (platform|crew + model), command, interactive (prompt + target session), or graph (target_graph_id).", + "type": "object", + "properties": { + "platform": { "type": "string" }, + "crew": { "type": "string" }, + "model": { "type": "string" }, + "effort": { "type": "string" }, + "prompt": { "type": "string" }, + "command": { "type": "string" }, + "target_session_id": { "type": "string" }, + "target_session_name": { "type": "string" }, + "timeout_minutes": { "type": "integer", "minimum": 0 }, + "target_graph_id": { "type": "string" }, + "queue_id": { "type": "string" }, + "workdir_override": { "type": "string" }, + "idea": { "type": "string" } + }, + "additionalProperties": false + }, + "hooks": { + "description": "The graph's completion hooks, keyed by the four events they fire on.", + "type": "object", + "properties": { + "on_completed": { + "type": "array", + "items": { "$ref": "#/definitions/hook" } + }, + "on_failed": { + "type": "array", + "items": { "$ref": "#/definitions/hook" } + }, + "on_blocked": { + "type": "array", + "items": { "$ref": "#/definitions/hook" } + }, + "on_spec_completed": { + "type": "array", + "items": { "$ref": "#/definitions/hook" } + } + }, + "additionalProperties": false + } + } +} diff --git a/schema/node.schema.json b/schema/node.schema.json new file mode 100644 index 0000000..3c67a75 --- /dev/null +++ b/schema/node.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graphs.univerlab.org/schema/node.schema.json", + "title": "Canopy shareable node document", + "description": "One reusable node or ensemble for the catalogue (format_version 1). The platform/model/cli binding ban is enforced by the validator at any config depth, not by this schema.", + "type": "object", + "required": ["format_version", "name", "kind", "description", "config"], + "properties": { + "format_version": { "const": 1 }, + "name": { + "type": "string", + "pattern": "^[a-z0-9-]{1,48}$" + }, + "kind": { "enum": ["agent", "check", "gate", "ensemble"] }, + "description": { "type": "string" }, + "config": { "type": "object" }, + "preset": { + "type": "object", + "required": ["name", "content"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "content": true + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..8327868 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +import graphSchema from "../schema/graph.schema.json" with { type: "json" }; +import nodeSchema from "../schema/node.schema.json" with { type: "json" }; +import { SLUG_PATTERN, SLUG_RE } from "./slug.js"; + +/** + * The single contract the worker and Canopy's `graph validate` agree on. + * Imported from `schema/`, never copied. + */ +export { graphSchema, nodeSchema }; +export { SLUG_PATTERN, SLUG_RE }; + +export type DocumentKind = "graph" | "node"; + +/** JSON value helpers for schema-typed documents. */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export function schemaFor(kind: DocumentKind): unknown { + return kind === "graph" ? graphSchema : nodeSchema; +} diff --git a/src/slug.ts b/src/slug.ts new file mode 100644 index 0000000..b8b0181 --- /dev/null +++ b/src/slug.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + +/** Slug rule shared by the graph and node schemas: `^[a-z0-9-]{1,48}$`. */ +export const SLUG_PATTERN = "^[a-z0-9-]{1,48}$"; + +export const SLUG_RE = /^[a-z0-9-]{1,48}$/; + +/** True when `value` is a string matching the shared slug rule. */ +export function isSlug(value: unknown): value is string { + return typeof value === "string" && SLUG_RE.test(value); +} + +/** + * True when a document folder's basename equals the document's slug-safe + * name (the publish rule: `graphs//` holds the document named + * ``). + */ +export function folderMatchesName(folderPath: string, name: string): boolean { + return path.basename(path.resolve(folderPath)) === name; +} diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..77cbdb4 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { type Schema, Validator } from "@cfworker/json-schema"; +import { graphSchema, nodeSchema, type DocumentKind } from "./schema.js"; +import { folderMatchesName } from "./slug.js"; + +/** One validation failure: the file, a JSON pointer, and what is wrong. */ +export interface ValidationFailure { + file: string; + pointer: string; + message: string; +} + +interface SchemaError { + instanceLocation?: unknown; + error?: unknown; + message?: unknown; +} + +interface SchemaResult { + valid?: unknown; + errors?: unknown; +} + +const validators: Record = { + graph: new Validator(graphSchema as Schema), + node: new Validator(nodeSchema as Schema), +}; + +function asString(value: unknown, fallback: string): string { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +/** Schema failures for one parsed document. */ +export function validateDocument( + file: string, + kind: DocumentKind, + doc: unknown, +): ValidationFailure[] { + const result = validators[kind].validate(doc) as SchemaResult; + if (result.valid !== false) { + return []; + } + const errors = Array.isArray(result.errors) ? result.errors : []; + return (errors as SchemaError[]).map((entry) => ({ + file, + pointer: asString(entry.instanceLocation, "#"), + message: asString(entry.error, asString(entry.message, "does not match schema")), + })); +} + +const BANNED_CONFIG_KEYS = new Set(["platform", "model", "cli"]); + +function escapePointer(segment: string): string { + return segment.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +/** Pointers (under `#/config`) where a banned key occurs at any depth. */ +export function findBannedConfigKeys(config: unknown): string[] { + const found: string[] = []; + const walk = (value: unknown, pointer: string): void => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${pointer}/${index}`)); + return; + } + if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + const childPointer = `${pointer}/${escapePointer(key)}`; + if (BANNED_CONFIG_KEYS.has(key)) { + found.push(childPointer); + } + walk(child, childPointer); + } + } + }; + walk(config, "#/config"); + return found; +} + +/** Node documents must not bind platform/model/cli anywhere in `config`. */ +export function validateNodeConfig(file: string, doc: unknown): ValidationFailure[] { + if (typeof doc !== "object" || doc === null) { + return []; + } + const config = (doc as { config?: unknown }).config; + if (typeof config !== "object" || config === null) { + return []; + } + return findBannedConfigKeys(config).map((pointer) => ({ + file, + pointer, + message: `config must not contain platform, model or cli bindings`, + })); +} + +function readJson(file: string): { doc: unknown } | { failure: ValidationFailure } { + try { + return { doc: JSON.parse(readFileSync(file, "utf8")) as unknown }; + } catch (error) { + return { + failure: { + file, + pointer: "#", + message: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +} + +function kindForFile(file: string): DocumentKind | null { + const base = path.basename(file); + if (base === "graph.json") { + return "graph"; + } + if (base === "node.json") { + return "node"; + } + return null; +} + +/** Schema (+ node config) validation for one document file. */ +export function validateDocumentFile(file: string): ValidationFailure[] { + const kind = kindForFile(file); + if (kind === null) { + return [{ file, pointer: "#", message: "unknown document kind (want graph.json or node.json)" }]; + } + const read = readJson(file); + if ("failure" in read) { + return [read.failure]; + } + const failures = validateDocument(file, kind, read.doc); + if (kind === "node") { + failures.push(...validateNodeConfig(file, read.doc)); + } + return failures; +} + +/** + * Full validation for one document folder: its document(s), the required + * README.md, and folder-name-equals-document-name. + */ +export function validateFolder(folder: string): ValidationFailure[] { + const failures: ValidationFailure[] = []; + const files = ["graph.json", "node.json"] + .map((name) => path.join(folder, name)) + .filter((file) => existsSync(file) && statSync(file).isFile()); + if (files.length === 0) { + return [{ file: folder, pointer: "#", message: "no graph.json or node.json document" }]; + } + if (!existsSync(path.join(folder, "README.md"))) { + failures.push({ file: path.join(folder, "README.md"), pointer: "#", message: "missing README.md" }); + } + for (const file of files) { + failures.push(...validateDocumentFile(file)); + const read = readJson(file); + if ("failure" in read) { + continue; + } + const name = + typeof read.doc === "object" && read.doc !== null + ? (read.doc as { name?: unknown }).name + : undefined; + if (typeof name === "string" && !folderMatchesName(folder, name)) { + failures.push({ + file, + pointer: "#/name", + message: `folder name must equal the document name "${name}"`, + }); + } + } + return failures; +} + +function collectFolders(root: string, out: string[]): void { + let entries: string[] = []; + try { + entries = readdirSync(root); + } catch { + return; + } + if (entries.includes("graph.json") || entries.includes("node.json")) { + out.push(root); + return; + } + for (const entry of entries) { + const full = path.join(root, entry); + try { + if (statSync(full).isDirectory()) { + collectFolders(full, out); + } + } catch { + continue; + } + } +} + +/** Validate every document folder under a registry root. */ +export function validateRegistry(registryDir: string): ValidationFailure[] { + const folders: string[] = []; + collectFolders(registryDir, folders); + return folders.sort().flatMap((folder) => validateFolder(folder)); +} + +/** Validate one CLI target: a file, a folder, or a registry root. */ +export function validateTarget(target: string): ValidationFailure[] { + const resolved = path.resolve(target); + if (!existsSync(resolved)) { + return [{ file: target, pointer: "#", message: "no such file or directory" }]; + } + if (statSync(resolved).isFile()) { + return validateDocumentFile(resolved); + } + const entries = readdirSync(resolved); + if (entries.includes("graph.json") || entries.includes("node.json")) { + return validateFolder(resolved); + } + return validateRegistry(resolved); +} + +function report(failures: ValidationFailure[]): void { + for (const failure of failures) { + process.stdout.write(`${failure.file} ${failure.pointer} ${failure.message}\n`); + } +} + +function run(argv: string[]): number { + const cwd = process.cwd(); + const targets = argv.length > 0 ? argv : [path.join(cwd, "registry")]; + const failures = targets.flatMap((target) => validateTarget(path.resolve(cwd, target))); + report(failures); + if (failures.length > 0) { + process.stderr.write(`${failures.length} failure(s)\n`); + return 1; + } + process.stdout.write("all documents valid\n"); + return 0; +} + +const invokedAsCli = process.argv[1] !== undefined && process.argv[1].endsWith("validate.ts"); +if (invokedAsCli) { + process.exitCode = run(process.argv.slice(2)); +} diff --git a/tests/fixtures/bad-slug-node/README.md b/tests/fixtures/bad-slug-node/README.md new file mode 100644 index 0000000..2f734c0 --- /dev/null +++ b/tests/fixtures/bad-slug-node/README.md @@ -0,0 +1,3 @@ +# bad-slug-node + +Failing fixture for bad-slug-node. diff --git a/tests/fixtures/bad-slug-node/node.json b/tests/fixtures/bad-slug-node/node.json new file mode 100644 index 0000000..d41ba3a --- /dev/null +++ b/tests/fixtures/bad-slug-node/node.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "name": "Bad_Slug!", + "kind": "agent", + "description": "Example catalogue node: drafts an implementation plan.", + "config": { + "prompt_template": "Draft a plan for {{spec_name}}.", + "timeout_minutes": 15 + }, + "preset": { + "name": "quick", + "content": { + "timeout_minutes": 5 + } + } +} diff --git a/tests/fixtures/missing-readme-graph/graph.json b/tests/fixtures/missing-readme-graph/graph.json new file mode 100644 index 0000000..1754ea5 --- /dev/null +++ b/tests/fixtures/missing-readme-graph/graph.json @@ -0,0 +1,85 @@ +{ + "format_version": 6, + "name": "missing-readme-graph", + "description": "Minimal example graph: implement, check, gate, and a router with two routes.", + "nodes": [ + { + "name": "implementer", + "kind": "agent", + "position": 1, + "config": { + "prompt_template": "Implement the spec." + } + }, + { + "name": "triage", + "kind": "router", + "position": 2, + "config": { + "routes": [ + { "label": "escalate", "description": "Needs a human." }, + { "label": "proceed", "description": "Safe to continue." } + ], + "fallback": "escalate" + } + }, + { + "name": "tests", + "kind": "check", + "position": 3, + "config": { + "command": "npm test" + } + }, + { + "name": "gate", + "kind": "gate", + "position": 4, + "config": { + "output_contains": "passing" + } + } + ], + "edges": [ + { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, + { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "tests", "to_node": "gate", "condition": "always" }, + { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, + { "from_node": "implementer", "to_node": "tests", "condition": "error" } + ], + "ensembles": [ + { + "name": "reviewers", + "kind": "parallel", + "commit_rights": false, + "prompt_template": "Review the diff for defects.", + "entry_from_node": "gate", + "entry_condition": "pass", + "extra_entry_sources": [], + "on_pass_to": "tests", + "min_pass": 1, + "timeout_minutes": 30, + "members": [ + { "prompt_override": "Review for correctness." }, + { "prompt_override": "Review for style.", "timeout_minutes": 10 } + ] + } + ], + "infra_node": "tests", + "hooks": { + "on_completed": [ + { "command": "echo done" } + ], + "on_failed": [], + "on_blocked": [], + "on_spec_completed": [] + }, + "presets": { + "quick": { + "content": { + "timeout_minutes": 10 + } + } + } +} diff --git a/tests/fixtures/name-mismatch-node/README.md b/tests/fixtures/name-mismatch-node/README.md new file mode 100644 index 0000000..a069f00 --- /dev/null +++ b/tests/fixtures/name-mismatch-node/README.md @@ -0,0 +1,3 @@ +# name-mismatch-node + +Failing fixture for name-mismatch-node. diff --git a/tests/fixtures/name-mismatch-node/node.json b/tests/fixtures/name-mismatch-node/node.json new file mode 100644 index 0000000..1922f70 --- /dev/null +++ b/tests/fixtures/name-mismatch-node/node.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "name": "right-name", + "kind": "agent", + "description": "Example catalogue node: drafts an implementation plan.", + "config": { + "prompt_template": "Draft a plan for {{spec_name}}.", + "timeout_minutes": 15 + }, + "preset": { + "name": "quick", + "content": { + "timeout_minutes": 5 + } + } +} diff --git a/tests/fixtures/nested-platform-node/README.md b/tests/fixtures/nested-platform-node/README.md new file mode 100644 index 0000000..a4ff56f --- /dev/null +++ b/tests/fixtures/nested-platform-node/README.md @@ -0,0 +1,3 @@ +# nested-platform-node + +Failing fixture for nested-platform-node. diff --git a/tests/fixtures/nested-platform-node/node.json b/tests/fixtures/nested-platform-node/node.json new file mode 100644 index 0000000..4059955 --- /dev/null +++ b/tests/fixtures/nested-platform-node/node.json @@ -0,0 +1,20 @@ +{ + "format_version": 1, + "name": "nested-platform-node", + "kind": "agent", + "description": "Example catalogue node: drafts an implementation plan.", + "config": { + "prompt_template": "hi", + "steps": [ + { + "platform": "claude" + } + ] + }, + "preset": { + "name": "quick", + "content": { + "timeout_minutes": 5 + } + } +} diff --git a/tests/fixtures/unknown-field-graph/README.md b/tests/fixtures/unknown-field-graph/README.md new file mode 100644 index 0000000..b941921 --- /dev/null +++ b/tests/fixtures/unknown-field-graph/README.md @@ -0,0 +1,3 @@ +# unknown-field-graph + +Failing fixture for unknown-field-graph. diff --git a/tests/fixtures/unknown-field-graph/graph.json b/tests/fixtures/unknown-field-graph/graph.json new file mode 100644 index 0000000..44dbb0e --- /dev/null +++ b/tests/fixtures/unknown-field-graph/graph.json @@ -0,0 +1,129 @@ +{ + "format_version": 6, + "name": "unknown-field-graph", + "description": "Minimal example graph: implement, check, gate, and a router with two routes.", + "nodes": [ + { + "name": "implementer", + "kind": "agent", + "position": 1, + "config": { + "prompt_template": "Implement the spec." + } + }, + { + "name": "triage", + "kind": "router", + "position": 2, + "config": { + "routes": [ + { + "label": "escalate", + "description": "Needs a human." + }, + { + "label": "proceed", + "description": "Safe to continue." + } + ], + "fallback": "escalate" + } + }, + { + "name": "tests", + "kind": "check", + "position": 3, + "config": { + "command": "npm test" + } + }, + { + "name": "gate", + "kind": "gate", + "position": 4, + "config": { + "output_contains": "passing" + } + } + ], + "edges": [ + { + "from_node": "implementer", + "to_node": "triage", + "condition": "pass" + }, + { + "from_node": "triage", + "to_node": "tests", + "condition": { + "condition": "route", + "route": "proceed" + } + }, + { + "from_node": "triage", + "to_node": "implementer", + "condition": { + "condition": "route", + "route": "escalate" + } + }, + { + "from_node": "tests", + "to_node": "gate", + "condition": "always" + }, + { + "from_node": "tests", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "implementer", + "to_node": "tests", + "condition": "error" + } + ], + "ensembles": [ + { + "name": "reviewers", + "kind": "parallel", + "commit_rights": false, + "prompt_template": "Review the diff for defects.", + "entry_from_node": "gate", + "entry_condition": "pass", + "extra_entry_sources": [], + "on_pass_to": "tests", + "min_pass": 1, + "timeout_minutes": 30, + "members": [ + { + "prompt_override": "Review for correctness." + }, + { + "prompt_override": "Review for style.", + "timeout_minutes": 10 + } + ] + } + ], + "infra_node": "tests", + "hooks": { + "on_completed": [ + { + "command": "echo done" + } + ], + "on_failed": [], + "on_blocked": [], + "on_spec_completed": [] + }, + "presets": { + "quick": { + "content": { + "timeout_minutes": 10 + } + } + }, + "future_field": 1 +} diff --git a/tests/fixtures/valid-graph/README.md b/tests/fixtures/valid-graph/README.md new file mode 100644 index 0000000..60bd9cc --- /dev/null +++ b/tests/fixtures/valid-graph/README.md @@ -0,0 +1,5 @@ +# valid-graph + +Example catalogue graph: an implementer agent, a triage router, a test +check, and a gate, plus a parallel reviewer ensemble and a completion +hook. diff --git a/tests/fixtures/valid-graph/graph.json b/tests/fixtures/valid-graph/graph.json new file mode 100644 index 0000000..a24eb5d --- /dev/null +++ b/tests/fixtures/valid-graph/graph.json @@ -0,0 +1,85 @@ +{ + "format_version": 6, + "name": "valid-graph", + "description": "Minimal example graph: implement, check, gate, and a router with two routes.", + "nodes": [ + { + "name": "implementer", + "kind": "agent", + "position": 1, + "config": { + "prompt_template": "Implement the spec." + } + }, + { + "name": "triage", + "kind": "router", + "position": 2, + "config": { + "routes": [ + { "label": "escalate", "description": "Needs a human." }, + { "label": "proceed", "description": "Safe to continue." } + ], + "fallback": "escalate" + } + }, + { + "name": "tests", + "kind": "check", + "position": 3, + "config": { + "command": "npm test" + } + }, + { + "name": "gate", + "kind": "gate", + "position": 4, + "config": { + "output_contains": "passing" + } + } + ], + "edges": [ + { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, + { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "tests", "to_node": "gate", "condition": "always" }, + { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, + { "from_node": "implementer", "to_node": "tests", "condition": "error" } + ], + "ensembles": [ + { + "name": "reviewers", + "kind": "parallel", + "commit_rights": false, + "prompt_template": "Review the diff for defects.", + "entry_from_node": "gate", + "entry_condition": "pass", + "extra_entry_sources": [], + "on_pass_to": "tests", + "min_pass": 1, + "timeout_minutes": 30, + "members": [ + { "prompt_override": "Review for correctness." }, + { "prompt_override": "Review for style.", "timeout_minutes": 10 } + ] + } + ], + "infra_node": "tests", + "hooks": { + "on_completed": [ + { "command": "echo done" } + ], + "on_failed": [], + "on_blocked": [], + "on_spec_completed": [] + }, + "presets": { + "quick": { + "content": { + "timeout_minutes": 10 + } + } + } +} diff --git a/tests/fixtures/valid-node/README.md b/tests/fixtures/valid-node/README.md new file mode 100644 index 0000000..e78fd09 --- /dev/null +++ b/tests/fixtures/valid-node/README.md @@ -0,0 +1,5 @@ +# valid-node + +Example catalogue node: an agent that drafts an implementation plan. +Carries no platform, model, or cli bindings — the consumer supplies +those at use time. diff --git a/tests/fixtures/valid-node/node.json b/tests/fixtures/valid-node/node.json new file mode 100644 index 0000000..d1295ca --- /dev/null +++ b/tests/fixtures/valid-node/node.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "name": "valid-node", + "kind": "agent", + "description": "Example catalogue node: drafts an implementation plan.", + "config": { + "prompt_template": "Draft a plan for {{spec_name}}.", + "timeout_minutes": 15 + }, + "preset": { + "name": "quick", + "content": { + "timeout_minutes": 5 + } + } +} diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..4d2ba60 --- /dev/null +++ b/tests/validate.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { validateDocumentFile, validateFolder } from "../src/validate.js"; + +const fixtures = path.resolve(import.meta.dirname, "fixtures"); + +function folder(name: string): string { + return path.join(fixtures, name); +} + +describe("catalogue validator", () => { + it("accepts a valid graph document folder", () => { + expect(validateFolder(folder("valid-graph"))).toEqual([]); + }); + + it("accepts a valid node document folder", () => { + expect(validateFolder(folder("valid-node"))).toEqual([]); + }); + + it("rejects an unknown field", () => { + const failures = validateDocumentFile(path.join(folder("unknown-field-graph"), "graph.json")); + expect(failures.length).toBeGreaterThan(0); + expect(failures.some((failure) => failure.message.includes("additional properties"))).toBe(true); + }); + + it("rejects a bad slug", () => { + const failures = validateDocumentFile(path.join(folder("bad-slug-node"), "node.json")); + expect(failures.length).toBeGreaterThan(0); + expect( + failures.some( + (failure) => failure.pointer === "#/name" && failure.message.includes("pattern"), + ), + ).toBe(true); + }); + + it("rejects platform inside a node config", () => { + const failures = validateDocumentFile(path.join(folder("nested-platform-node"), "node.json")); + expect(failures.length).toBeGreaterThan(0); + expect( + failures.some((failure) => failure.pointer.startsWith("#/config")), + ).toBe(true); + }); + + it("rejects a missing README", () => { + const failures = validateFolder(folder("missing-readme-graph")); + expect( + failures.some((failure) => failure.message.includes("README")), + ).toBe(true); + }); + + it("rejects a folder/name mismatch", () => { + const failures = validateFolder(folder("name-mismatch-node")); + expect( + failures.some( + (failure) => failure.pointer === "#/name" && failure.message.includes("folder name"), + ), + ).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9605610 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "declaration": false, + "sourceMap": false, + "outDir": "dist", + "types": ["node"], + "lib": ["ES2022"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..213189c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..5ce841e --- /dev/null +++ b/worker/README.md @@ -0,0 +1,7 @@ +# Catalogue worker (graphs.univerlab.org) + +Serves the validated documents under `registry/` and the schemas under +`schema/`. + +The worker imports the schemas from `../schema/` — it never copies them, +so the contract the worker enforces is always the one in this repo. From 1b941717467367c9288d0514153fdf71f639d63a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 17:34:39 -0500 Subject: [PATCH 003/180] feat(schema): define the graph and node document schemas with a validate command and CI on pull requests (reviewer 2 fixes) --- README.md | 4 +- registry/graphs/hello-graph/graph.json | 4 +- schema/graph.schema.json | 10 ++--- src/validate.ts | 5 +++ .../fixtures/missing-readme-graph/graph.json | 4 +- tests/fixtures/unknown-field-graph/graph.json | 2 - tests/fixtures/valid-graph/graph.json | 4 +- tests/validate.test.ts | 40 ++++++++++++++++++- 8 files changed, 56 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 127aed6..0979885 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ must equal the document's slug-safe `name` (`^[a-z0-9-]{1,48}$`). ## How to publish -1. Put the document at `graphs//graph.json` (graphs) or - `nodes//node.json` (nodes), where `` matches the +1. Put the document at `registry/graphs//graph.json` (graphs) or + `registry/nodes//node.json` (nodes), where `` matches the document's `name`. 2. Add a `README.md` beside it describing what the document does. 3. Add the `canopy-graph` topic to the GitHub repository. diff --git a/registry/graphs/hello-graph/graph.json b/registry/graphs/hello-graph/graph.json index 0ba7a5b..2e65c2d 100644 --- a/registry/graphs/hello-graph/graph.json +++ b/registry/graphs/hello-graph/graph.json @@ -42,8 +42,8 @@ ], "edges": [ { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, - { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, - { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "triage", "to_node": "tests", "condition": { "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "route": "escalate" } }, { "from_node": "tests", "to_node": "gate", "condition": "always" }, { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, { "from_node": "implementer", "to_node": "tests", "condition": "error" } diff --git a/schema/graph.schema.json b/schema/graph.schema.json index 6a0f321..2a1d6f4 100644 --- a/schema/graph.schema.json +++ b/schema/graph.schema.json @@ -51,14 +51,13 @@ "pattern": "^[a-z0-9-]{1,48}$" }, "condition": { - "description": "Routing condition: pass, fail, always, error (break reads as error), or a router label {condition: route, route}.", + "description": "Routing condition: pass, fail, always, error (break reads as error), or a router label {route}. Matches serde's externally-tagged encoding of GraphEdgeCondition::Route.", "anyOf": [ { "enum": ["pass", "fail", "always", "error", "break"] }, { "type": "object", - "required": ["condition", "route"], + "required": ["route"], "properties": { - "condition": { "const": "route" }, "route": { "type": "string", "minLength": 1 } }, "additionalProperties": false @@ -159,7 +158,7 @@ "additionalProperties": false }, "hook": { - "description": "One graph completion hook: agent (platform|crew + model), command, interactive (prompt + target session), or graph (target_graph_id).", + "description": "One graph completion hook: agent (platform|crew + model), command, interactive (prompt + target session), or graph (target_graph_id). Open to extra fields: GraphCompletionHook has no deny_unknown_fields.", "type": "object", "properties": { "platform": { "type": "string" }, @@ -175,8 +174,7 @@ "queue_id": { "type": "string" }, "workdir_override": { "type": "string" }, "idea": { "type": "string" } - }, - "additionalProperties": false + } }, "hooks": { "description": "The graph's completion hooks, keyed by the four events they fire on.", diff --git a/src/validate.ts b/src/validate.ts index 77cbdb4..366ad2a 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -209,6 +209,11 @@ export function validateTarget(target: string): ValidationFailure[] { return [{ file: target, pointer: "#", message: "no such file or directory" }]; } if (statSync(resolved).isFile()) { + // A document file is always validated as its folder: the publish layout + // requires the README.md and folder-name-equals-document-name rules too. + if (kindForFile(resolved) !== null) { + return validateFolder(path.dirname(resolved)); + } return validateDocumentFile(resolved); } const entries = readdirSync(resolved); diff --git a/tests/fixtures/missing-readme-graph/graph.json b/tests/fixtures/missing-readme-graph/graph.json index 1754ea5..6a92b71 100644 --- a/tests/fixtures/missing-readme-graph/graph.json +++ b/tests/fixtures/missing-readme-graph/graph.json @@ -42,8 +42,8 @@ ], "edges": [ { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, - { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, - { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "triage", "to_node": "tests", "condition": { "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "route": "escalate" } }, { "from_node": "tests", "to_node": "gate", "condition": "always" }, { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, { "from_node": "implementer", "to_node": "tests", "condition": "error" } diff --git a/tests/fixtures/unknown-field-graph/graph.json b/tests/fixtures/unknown-field-graph/graph.json index 44dbb0e..ee76e40 100644 --- a/tests/fixtures/unknown-field-graph/graph.json +++ b/tests/fixtures/unknown-field-graph/graph.json @@ -56,7 +56,6 @@ "from_node": "triage", "to_node": "tests", "condition": { - "condition": "route", "route": "proceed" } }, @@ -64,7 +63,6 @@ "from_node": "triage", "to_node": "implementer", "condition": { - "condition": "route", "route": "escalate" } }, diff --git a/tests/fixtures/valid-graph/graph.json b/tests/fixtures/valid-graph/graph.json index a24eb5d..fcdfd19 100644 --- a/tests/fixtures/valid-graph/graph.json +++ b/tests/fixtures/valid-graph/graph.json @@ -42,8 +42,8 @@ ], "edges": [ { "from_node": "implementer", "to_node": "triage", "condition": "pass" }, - { "from_node": "triage", "to_node": "tests", "condition": { "condition": "route", "route": "proceed" } }, - { "from_node": "triage", "to_node": "implementer", "condition": { "condition": "route", "route": "escalate" } }, + { "from_node": "triage", "to_node": "tests", "condition": { "route": "proceed" } }, + { "from_node": "triage", "to_node": "implementer", "condition": { "route": "escalate" } }, { "from_node": "tests", "to_node": "gate", "condition": "always" }, { "from_node": "tests", "to_node": "implementer", "condition": "fail" }, { "from_node": "implementer", "to_node": "tests", "condition": "error" } diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 4d2ba60..9e71269 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import path from "node:path"; import { describe, expect, it } from "vitest"; -import { validateDocumentFile, validateFolder } from "../src/validate.js"; +import { validateDocument, validateDocumentFile, validateFolder, validateTarget } from "../src/validate.js"; const fixtures = path.resolve(import.meta.dirname, "fixtures"); @@ -57,4 +57,42 @@ describe("catalogue validator", () => { ), ).toBe(true); }); + + it("accepts a real Rust route condition {route}", () => { + const doc = { + format_version: 6, + name: "g", + nodes: [], + edges: [{ from_node: "a", to_node: "b", condition: { route: "proceed" } }], + }; + expect(validateDocument("g/graph.json", "graph", doc)).toEqual([]); + }); + + it("rejects the {condition, route} shape Rust never wrote", () => { + const doc = { + format_version: 6, + name: "g", + nodes: [], + edges: [{ from_node: "a", to_node: "b", condition: { condition: "route", route: "proceed" } }], + }; + expect(validateDocument("g/graph.json", "graph", doc).length).toBeGreaterThan(0); + }); + + it("accepts unknown hook fields, like Rust import does", () => { + const doc = { + format_version: 6, + name: "g", + nodes: [], + edges: [], + hooks: { on_completed: [{ command: "echo done", future_hook_field: 1 }] }, + }; + expect(validateDocument("g/graph.json", "graph", doc)).toEqual([]); + }); + + it("enforces folder rules when given a single file", () => { + const missing = validateTarget(path.join(folder("missing-readme-graph"), "graph.json")); + expect(missing.some((failure) => failure.message.includes("README"))).toBe(true); + const mismatch = validateTarget(path.join(folder("name-mismatch-node"), "node.json")); + expect(mismatch.some((failure) => failure.message.includes("folder name"))).toBe(true); + }); }); From 157533790f11b2fc719db04d78ebfce267bff772 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 17:44:51 -0500 Subject: [PATCH 004/180] feat(worker): redirect graph and node installs to GitHub and count them --- .github/workflows/deploy.yml | 30 + README.md | 3 +- package-lock.json | 5728 ++++++++++++++++++++++++---------- package.json | 8 +- tsconfig.json | 11 +- worker/README.md | 96 +- worker/src/index.ts | 98 + worker/src/lib.ts | 248 ++ worker/test/index.test.ts | 269 ++ worker/vitest.config.ts | 15 + worker/wrangler.toml | 32 + 11 files changed, 4861 insertions(+), 1677 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 worker/src/index.ts create mode 100644 worker/src/lib.ts create mode 100644 worker/test/index.test.ts create mode 100644 worker/vitest.config.ts create mode 100644 worker/wrangler.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..cbabf7d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: deploy + +# Manual only: nothing deploys on merge. Publishes worker/ to Cloudflare as +# the `canopy-graphs` Worker behind graphs.univerlab.org. The account id is +# inlined in worker/wrangler.toml, so the only secret is the API token. +on: + workflow_dispatch: {} + +concurrency: + group: deploy-worker + cancel-in-progress: false + +jobs: + deploy: + name: Deploy worker + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Deploy with Wrangler + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + workingDirectory: worker + command: deploy diff --git a/README.md b/README.md index 0979885..5dbca59 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ must equal the document's slug-safe `name` (`^[a-z0-9-]{1,48}$`). | ----------------- | ----------------------------------------------- | | `npm run lint` | eslint over the repo | | `npm run typecheck` | `tsc --noEmit` (strict) | -| `npm test` | vitest suite | +| `npm test` | vitest suite (documents + worker) | +| `npm run test:worker` | worker suite only, in the Workers runtime | | `npm run validate [paths...]` | validate registry documents (files or folders; no args = all of `registry/`) | The schemas in `schema/` are the single contract the worker and diff --git a/package-lock.json b/package-lock.json index d880f3b..c336365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "@cfworker/json-schema": "^4.1.1" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.12.21", + "@cloudflare/workers-types": "^5.20261026.1", "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "wrangler": "^4.153.0" }, "engines": { "node": ">=22" @@ -29,10 +32,81 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.12.21", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.12.21.tgz", + "integrity": "sha512-xqvqVR+qAhekXWaTNY36UtFFmHrz13yGUoWVGOu6LDC2ABiQqI1E1lQ3eUZY8KVB+1FXY/mP5dB6oD07XUGnPg==", + "deprecated": "@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "^1.2.3", + "esbuild": "0.27.3", + "miniflare": "4.20260310.0", + "wrangler": "4.72.0" + }, + "peerDependencies": { + "@vitest/runner": "2.0.x - 3.2.x", + "@vitest/snapshot": "2.0.x - 3.2.x", + "vitest": "2.0.x - 3.2.x" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz", + "integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -46,10 +120,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -63,10 +137,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -80,10 +154,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -97,10 +171,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -114,10 +188,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -131,10 +205,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -148,10 +222,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -165,10 +239,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -182,10 +256,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -199,10 +273,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -216,10 +290,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -233,10 +307,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -250,10 +324,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -267,10 +341,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -284,10 +358,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -301,10 +375,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -318,10 +392,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -335,10 +409,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -352,10 +426,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -369,10 +443,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -386,10 +460,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -403,10 +477,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -420,10 +494,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -437,10 +511,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -454,10 +528,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -471,275 +545,271 @@ "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@cloudflare/vitest-pool-workers/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.72.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.72.0.tgz", + "integrity": "sha512-bKkb8150JGzJZJWiNB2nu/33smVfawmfYiecA6rW4XH7xS23/jqMbgpdelM34W/7a1IhR66qeQGVqTRXROtAZg==", + "dev": true, + "license": "MIT OR Apache-2.0", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.15.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260310.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260310.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=20.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "optionalDependencies": { + "fsevents": "~2.3.2" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@cloudflare/workers-types": "^4.20260310.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260310.1.tgz", + "integrity": "sha512-hF2VpoWaMb1fiGCQJqCY6M8I+2QQqjkyY4LiDYdTL5D/w6C1l5v1zhc0/jrjdD1DXfpJtpcSMSmEPjHse4p9Ig==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=16" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260310.1.tgz", + "integrity": "sha512-h/Vl3XrYYPI6yFDE27XO1QPq/1G1lKIM8tzZGIWYpntK3IN5XtH3Ee/sLaegpJ49aIJoqhF2mVAZ6Yw+Vk2gJw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=16" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260310.1.tgz", + "integrity": "sha512-XzQ0GZ8G5P4d74bQYOIP2Su4CLdNPpYidrInaSOuSxMw+HamsHaFrjVsrV2mPy/yk2hi6SY2yMbgKFK9YjA7vw==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260310.1.tgz", + "integrity": "sha512-sxv4CxnN4ZR0uQGTFVGa0V4KTqwdej/czpIc5tYS86G8FQQoGIBiAIs2VvU7b8EROPcandxYHDBPTb+D9HIMPw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260310.1.tgz", + "integrity": "sha512-+1ZTViWKJypLfgH/luAHCqkent0DEBjAjvO40iAhOMHRLYP/SPphLvr4Jpi6lb+sIocS8Q1QZL4uM5Etg1Wskg==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20261026.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261026.1.tgz", + "integrity": "sha512-E+KMLXOMip7uzfkFYoSjAF0n0+VFkK2KFU0Md/PM3JkPpC9Km0/YAoDttkmxAoZLGbK1wQRAFg2IJtyZkTBCHg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@jridgewell/trace-mapping": "0.3.9" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "tslib": "^2.4.0" } }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ - "x64" + "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", - "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", - "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", - "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -748,12 +818,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", - "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -762,12 +835,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", - "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -776,12 +852,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", - "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -790,233 +869,219 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", - "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", - "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", - "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", - "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ - "arm64" + "ia32" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", - "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", - "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ - "loong64" + "mips64el" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", - "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", - "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ - "ppc64" + "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", - "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", - "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ - "riscv64" + "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", - "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", - "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", - "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", - "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1025,12 +1090,15 @@ "optional": true, "os": [ "openbsd" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", - "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1039,54 +1107,66 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", - "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", - "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", - "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ - "x64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", - "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1095,1657 +1175,3946 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/@types/node": { - "version": "22.20.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", - "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "@eslint/core": "^0.17.0" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "tinyrainbow": "^2.0.0" + "@types/json-schema": "^7.0.15" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://eslint.org/donate" } }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/acorn": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", - "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" }, "engines": { - "node": ">=0.4.0" + "node": ">=18.18.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">=12.22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=18" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" + "@img/sharp-wasm32": "0.35.5" }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@img/sharp-freebsd-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { - "node": ">=10" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "4.20260310.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260310.0.tgz", + "integrity": "sha512-uC5vNPenFpDSj5aUU3wGSABG6UUqMr+Xs1m4AkCrTHo37F4Z6xcQw5BXqViTfPDVT/zcYH1UgTVoXhr1l6ZMXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260310.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=6.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, "engines": { "node": ">=6" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "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" + "node": ">= 14.16" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "node": "^10 || ^12 || >=14" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=10" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, - "license": "BSD-2-Clause", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" + "url": "https://opencollective.com/libvips" }, - "engines": { - "node": ">=0.10" + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=4.0" + "node": ">=8" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=8" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } + "license": "ISC" }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" + "node": ">=8" }, - "engines": { - "node": ">=16.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" + "js-tokens": "^9.0.1" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=16" + "node": ">=8" } }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "license": "MIT" }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=10.13.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=14.0.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=6" + "node": ">=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, "engines": { - "node": ">=0.8.19" + "node": ">= 0.8.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=0.10.0" + "node": ">=14.17" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/undici": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, "engines": { - "node": ">=0.10.0" + "node": ">=20.18.1" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "pathe": "^2.0.3" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "json-buffer": "3.0.1" + "punycode": "^2.1.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">= 0.8.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=10" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": "*" + "node": ">= 8" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "why-is-node-running": "cli.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=8" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/workerd": { + "version": "1.20260310.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260310.1.tgz", + "integrity": "sha512-yawXhypXXHtArikJj15HOMknNGikpBbSg2ZDe6lddUbqZnJXuCVSkgc/0ArUeVMG1jbbGvpst+REFtKwILvRTQ==", "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" }, "engines": { - "node": ">=10" + "node": ">=16" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260310.1", + "@cloudflare/workerd-darwin-arm64": "1.20260310.1", + "@cloudflare/workerd-linux-64": "1.20260310.1", + "@cloudflare/workerd-linux-arm64": "1.20260310.1", + "@cloudflare/workerd-windows-64": "1.20260310.1" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/wrangler": { + "version": "4.153.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.153.0.tgz", + "integrity": "sha512-Hn1AowKzZao/729rMpdpxBZt/RaKQNJ4GtKtHQcOrwT+J/GIq0iwl680+dhJyO00iSsXaCkxoNcLg3yVIwIvyw==", "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "dependencies": { - "p-limit": "^3.0.2" + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261023.0-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261023.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=10" + "node": ">=22.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261023.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261023.1.tgz", + "integrity": "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261023.1.tgz", + "integrity": "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261023.1.tgz", + "integrity": "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261023.1.tgz", + "integrity": "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=16" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261023.1.tgz", + "integrity": "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=16" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/wrangler/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 14.16" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/libvips" } }, - "node_modules/postcss": { - "version": "8.5.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", - "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "libc": [ + "glibc" ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.20", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/rollup": { - "version": "4.65.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", - "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.65.1", - "@rollup/rollup-android-arm64": "4.65.1", - "@rollup/rollup-darwin-arm64": "4.65.1", - "@rollup/rollup-darwin-x64": "4.65.1", - "@rollup/rollup-freebsd-arm64": "4.65.1", - "@rollup/rollup-freebsd-x64": "4.65.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", - "@rollup/rollup-linux-arm-musleabihf": "4.65.1", - "@rollup/rollup-linux-arm64-gnu": "4.65.1", - "@rollup/rollup-linux-arm64-musl": "4.65.1", - "@rollup/rollup-linux-loong64-gnu": "4.65.1", - "@rollup/rollup-linux-loong64-musl": "4.65.1", - "@rollup/rollup-linux-ppc64-gnu": "4.65.1", - "@rollup/rollup-linux-ppc64-musl": "4.65.1", - "@rollup/rollup-linux-riscv64-gnu": "4.65.1", - "@rollup/rollup-linux-riscv64-musl": "4.65.1", - "@rollup/rollup-linux-s390x-gnu": "4.65.1", - "@rollup/rollup-linux-x64-gnu": "4.65.1", - "@rollup/rollup-linux-x64-musl": "4.65.1", - "@rollup/rollup-openbsd-x64": "4.65.1", - "@rollup/rollup-openharmony-arm64": "4.65.1", - "@rollup/rollup-win32-arm64-msvc": "4.65.1", - "@rollup/rollup-win32-ia32-msvc": "4.65.1", - "@rollup/rollup-win32-x64-gnu": "4.65.1", - "@rollup/rollup-win32-x64-msvc": "4.65.1", - "fsevents": "~2.3.2" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "node_modules/wrangler/node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "BSD-3-Clause", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/wrangler/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "node_modules/wrangler/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/antfu" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/wrangler/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/wrangler/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "node_modules/wrangler/node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" } }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, - "node_modules/tsx": { - "version": "4.23.15", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", - "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "node_modules/wrangler/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.0.0" + "node": ">=20.9.0" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/wrangler/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/wrangler/node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14.17" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/wrangler/node_modules/miniflare": { + "version": "5.20261023.0-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261023.0-beta.tgz", + "integrity": "sha512-SHY8A98gCvmrnBLqABHRTSh3duAL1z+tHPRZu/tqxHFqVARrZ4oQis/ALYwp3XvzHvs6B/ff/CdcCafqP+cOFA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "punycode": "^2.1.0" + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.30.1", + "workerd": "1.20261023.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" } }, - "node_modules/vite": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", - "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "node_modules/wrangler/node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@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" }, "peerDependenciesMeta": { "@types/node": { "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "node_modules/wrangler/node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261023.1.tgz", + "integrity": "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", "bin": { - "vite-node": "vite-node.mjs" + "workerd": "bin/workerd" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": ">=16" }, - "funding": { - "url": "https://opencollective.com/vitest" + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261023.1", + "@cloudflare/workerd-darwin-arm64": "1.20261023.1", + "@cloudflare/workerd-linux-64": "1.20261023.1", + "@cloudflare/workerd-linux-arm64": "1.20261023.1", + "@cloudflare/workerd-windows-64": "1.20261023.1" } }, - "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "node_modules/wrangler/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=10.0.0" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", - "happy-dom": "*", - "jsdom": "*" + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { + "bufferutil": { "optional": true }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { + "utf-8-validate": { "optional": true } } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" + "engines": { + "node": ">=10.0.0" }, - "bin": { - "why-is-node-running": "cli.js" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/yocto-queue": { @@ -2760,6 +5129,31 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } } } } diff --git a/package.json b/package.json index 1a9a592..35e5e72 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,22 @@ "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "vitest run", + "test": "vitest run && npm run test:worker", + "test:worker": "vitest run --config worker/vitest.config.ts", "validate": "tsx src/validate.ts" }, "dependencies": { "@cfworker/json-schema": "^4.1.1" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.12.21", + "@cloudflare/workers-types": "^5.20261026.1", "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "wrangler": "^4.153.0" } } diff --git a/tsconfig.json b/tsconfig.json index 9605610..8d07965 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,15 @@ "declaration": false, "sourceMap": false, "outDir": "dist", - "types": ["node"], + "types": ["node", "@cloudflare/vitest-pool-workers"], "lib": ["ES2022"] }, - "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "vitest.config.ts", + "worker/src/**/*.ts", + "worker/test/**/*.ts", + "worker/vitest.config.ts" + ] } diff --git a/worker/README.md b/worker/README.md index 5ce841e..2a7be31 100644 --- a/worker/README.md +++ b/worker/README.md @@ -1,7 +1,93 @@ -# Catalogue worker (graphs.univerlab.org) +# graphs.univerlab.org — install redirector -Serves the validated documents under `registry/` and the schemas under -`schema/`. +A tiny Cloudflare Worker that gives catalogue documents a clean install URL: -The worker imports the schemas from `../schema/` — it never copies them, -so the contract the worker enforces is always the one in this repo. +```sh +curl -fsSL https://graphs.univerlab.org/i/graph/hello-graph +canopy graph install hello-graph # what Canopy does under the hood +``` + +It **only 302-redirects** to the document on GitHub. The Worker never fetches +or proxies the document, so GitHub stays the download origin and the trust +anchor — this Worker is just a prettier doorway. + +## Routes + +| Route | Where it points | +|---|---| +| `GET /i//` | official: `https://raw.githubusercontent.com/UniverLab/graphs//registry/s//.json` | +| `GET /i////` | community: `https://raw.githubusercontent.com////s//.json` | + +- `` is `graph` or `node`. +- `?ref=` selects a branch, tag or commit sha; it defaults to `HEAD` (the + default branch). +- Every path segment is validated (GitHub owner/repo rules, the schema's + `^[a-z0-9-]{1,48}$` slug rule, a URL-safe ref). Anything invalid answers + `400` with a one-line reason; `GET /i/` answers `404` with the usage text. + +Official documents are the ones in this repo's `registry/` — the `registry/` +prefix is implicit in the short route, so `/i/graph/hello-graph` and the +community form `/i/UniverLab/graphs/graph/hello-graph` point at the same file. + +## What it counts + +One aggregate data point per request, via +[Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/): + +| Field | Example | Why | +|---|---|---| +| index | `UniverLab/graphs/graph/hello-graph` | which document is installed | +| kind | `graph` / `node` | graphs vs nodes | +| document | `UniverLab/graphs/hello-graph` | document, without repeating the kind | +| client | `canopy`, `curl`, `browser`, `other` | a real install, a script, or someone looking | +| canopy version | `1.7.2` | which Canopy release installs what | +| country | `CO` | rough geography | +| outcome | `redirect` / `bad-request` | 400s and 404s show what people expected to exist | + +**Not recorded**: IP addresses, full user-agents, cookies, anything tied to a +person. The client class is a coarse bucket, not the raw header. + +The write is fire-and-forget inside a `try/catch`: if the binding is missing +(local dev) or Analytics Engine is down, the redirect still happens. + +## Tests + +```sh +npm run test:worker # worker/ suite, in the Workers runtime via Miniflare +``` + +The suite covers both route shapes, `?ref=`, every 400 case, the `/i/` usage +page, the data point written for each outcome, and that a throwing or missing +binding still serves the redirect. `npm test` runs it together with the +document validator suite. + +## One-time setup (before the first deploy) + +1. **Enable Analytics Engine on the account** — dash.cloudflare.com → + *Storage & databases* → *Analytics Engine*. It is an account-level opt-in + and not under *Compute/Workers*, despite where the deploy error links. Until + it is enabled, a deploy that declares the binding fails with `code: 10089`, + whatever the token's permissions. The dataset `canopy_graph_installs` needs + no provisioning: it is created on the first write. +2. **Bind the custom domain** — Workers & Pages → `canopy-graphs` → Settings → + Domains & Routes → *Custom Domain* → `graphs.univerlab.org` (Cloudflare + provisions the TLS certificate). +3. **Set the repository secret** — `CLOUDFLARE_API_TOKEN`, a token with + *Edit Workers*. The account id is inlined in `wrangler.toml`, so no second + secret is needed. + +## Deploy + +`.github/workflows/deploy.yml`, **manual only** (`workflow_dispatch`): in the +trunk-based flow nothing deploys on merge. To deploy by hand: + +```sh +cd worker +npx wrangler deploy --dry-run # check the bundle and the binding +npx wrangler deploy +``` + +Reading the counts is deliberately not in this repo: Analytics Engine has no +dashboard, only the SQL API, and that needs a long-lived read token — which +does not belong on a laptop. Use the Cloudflare SQL API with +`SUM(_sample_interval)` (not `COUNT(*)`: the dataset is sampled under load). diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..6cf2103 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * graphs.univerlab.org — install-URL redirector for catalogue documents. + * + * curl -fsSL https://graphs.univerlab.org/i/graph/hello-graph + * canopy graph install hello-graph + * + * It answers every request with either a 302 to the document on GitHub or a + * 400/404 explaining why not. The Worker never fetches or proxies the + * document: GitHub stays the download origin and the trust anchor, so this + * adds no supply-chain surface beyond a redirect. + * + * It DOES record one aggregate data point per request (Analytics Engine): + * which document, which kind of client, the Canopy version when there is one, + * country and outcome. No IP, no full user-agent, no cookies, nothing + * per-person. The write is fire-and-forget inside a try/catch — if analytics + * is missing or broken, the redirect still happens. + */ +import { + type Outcome, + type Parts, + USAGE, + buildUrl, + canopyVersion, + clientClass, + documentKey, + indexKey, + parseTarget, +} from "./lib.js"; + +/** The single Analytics Engine method this Worker uses. */ +export interface DataPoint { + indexes?: string[]; + blobs?: string[]; + doubles?: number[]; +} + +export interface AnalyticsSink { + writeDataPoint(point: DataPoint): void; +} + +export interface Env { + /** Optional: absent in local dev and in tests, and the Worker must still serve. */ + INSTALLS?: AnalyticsSink; +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} + +/** Country is set by Cloudflare's edge; a local request has none. */ +function country(req: Request): string { + return (req as { cf?: { country?: string } }).cf?.country ?? ""; +} + +/** One aggregate data point. Never allowed to change the answer. */ +function record(env: Env, req: Request, parts: Parts, outcome: Outcome): void { + try { + const dataset = env?.INSTALLS; + if (!dataset) { + return; + } + const userAgent = req.headers.get("user-agent"); + dataset.writeDataPoint({ + indexes: [indexKey(parts)], + blobs: [ + parts.kind, + documentKey(parts), + clientClass(userAgent), + canopyVersion(userAgent), + country(req), + outcome, + ], + doubles: [1], + }); + } catch { + // Telemetry must never break an install. + } +} + +export default { + fetch(req: Request, env: Env): Response { + const url = new URL(req.url); + const parsed = parseTarget(url.pathname, url.searchParams.get("ref")); + + if (!parsed.ok) { + record(env, req, parsed.rejection, "bad-request"); + const body = parsed.rejection.status === 404 ? `${USAGE}\n` : `${parsed.rejection.reason}\n`; + return textResponse(body, parsed.rejection.status); + } + + record(env, req, parsed.target, "redirect"); + return Response.redirect(buildUrl(parsed.target), 302); + }, +}; diff --git a/worker/src/lib.ts b/worker/src/lib.ts new file mode 100644 index 0000000..131bff3 --- /dev/null +++ b/worker/src/lib.ts @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Pure route parsing for graphs.univerlab.org. + * + * Nothing here touches the network, the clock or a global: the Worker calls + * these functions and the tests call them too. The one contract that matters + * is the redirect URL — GitHub stays the download origin and trust anchor, so + * the Worker only ever answers "where is this document", never its content. + */ + +export type Kind = "graph" | "node"; + +export type Client = "canopy" | "curl" | "browser" | "other"; + +export type Outcome = "redirect" | "bad-request"; + +/** GitHub owner rule: 1-39 characters, alphanumerics or hyphens, no leading or trailing hyphen. */ +export const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; + +/** GitHub repository rule: 1-100 characters of letters, digits, `_`, `.` or `-`. */ +export const REPO_RE = /^[a-zA-Z0-9_.-]{1,100}$/; + +/** + * Literal copy of `src/slug.ts`'s `^[a-z0-9-]{1,48}$`. That module imports + * `node:path`, which does not exist in a Worker, so the rule is copied rather + * than imported; the worker test suite pins the two to the same string. + */ +export const SLUG_PATTERN = "^[a-z0-9-]{1,48}$"; + +export const SLUG_RE = /^[a-z0-9-]{1,48}$/; + +/** A git ref: branch, tag or commit sha. Never empty, never leading punctuation. */ +export const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/; + +export const DEFAULT_REF = "HEAD"; + +export const OFFICIAL_OWNER = "UniverLab"; + +export const OFFICIAL_REPO = "graphs"; + +/** Official documents live under the registry layout; community repos do not. */ +const OFFICIAL_PREFIX = "registry/"; + +/** The placeholder recorded for a part a bad request did not supply. */ +const UNKNOWN = "?"; + +export const USAGE = [ + "graphs.univerlab.org redirects to the document on GitHub.", + "", + " GET /i// official: UniverLab/graphs", + " GET /i//// community repository", + "", + " is graph or node; ?ref= defaults to HEAD.", + "", + "example: /i/graph/hello-graph", +].join("\n"); + +/** A parsed install target: everything needed to build the redirect URL. */ +export interface Target { + official: boolean; + owner: string; + repo: string; + kind: Kind; + slug: string; + ref: string; +} + +/** The countable parts of a request, with `?` where a bad request had none. */ +export interface Parts { + kind: string; + owner: string; + repo: string; + slug: string; +} + +/** A rejected request: 404 for the bare `/i/`, 400 for anything invalid. */ +export interface Rejection extends Parts { + status: 400 | 404; + reason: string; +} + +export type Parsed = + | { ok: true; target: Target } + | { ok: false; rejection: Rejection }; + +function reject(reason: string, parts: Partial = {}, status: 400 | 404 = 400): Parsed { + return { + ok: false, + rejection: { + status, + reason, + kind: parts.kind ?? UNKNOWN, + owner: parts.owner ?? UNKNOWN, + repo: parts.repo ?? UNKNOWN, + slug: parts.slug ?? UNKNOWN, + }, + }; +} + +function kindOf(segment: string): Kind | null { + return segment === "graph" || segment === "node" ? segment : null; +} + +/** Collapse the multi-line usage text into a single line for a 400 reason. */ +function onOneLine(text: string): string { + return text.replace(/\n/g, " "); +} + +function splitPath(pathname: string): string[] { + const trimmed = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + return trimmed === "" ? [] : trimmed.split("/"); +} + +type RefCheck = { ok: true; ref: string } | { ok: false; reason: string }; + +/** Read a `?ref=` value: absent means HEAD, but an empty one is still invalid. */ +function readRef(ref: string | null): RefCheck { + if (ref === null) { + return { ok: true, ref: DEFAULT_REF }; + } + if (!REF_RE.test(ref)) { + return { ok: false, reason: "ref must be a branch, tag or commit sha (1-128 URL-safe characters)" }; + } + return { ok: true, ref }; +} + +function finish( + official: boolean, + parts: { owner: string; repo: string; kind: Kind; slug: string }, + ref: string | null, +): Parsed { + const checked = readRef(ref); + if (!checked.ok) { + return reject(checked.reason, parts); + } + return { ok: true, target: { official, ...parts, ref: checked.ref } }; +} + +function official(kindSegment: string, slug: string, ref: string | null): Parsed { + const base = { owner: OFFICIAL_OWNER, repo: OFFICIAL_REPO }; + const kind = kindOf(kindSegment); + if (kind === null) { + return reject("kind must be graph or node", base); + } + if (!SLUG_RE.test(slug)) { + return reject("slug must be 1-48 characters of a-z, 0-9 or -", { ...base, kind }); + } + return finish(true, { ...base, kind, slug }, ref); +} + +function community( + owner: string, + repo: string, + kindSegment: string, + slug: string, + ref: string | null, +): Parsed { + const validOwner = OWNER_RE.test(owner); + const validRepo = REPO_RE.test(repo); + const kind = kindOf(kindSegment); + if (!validOwner) { + return reject("owner must follow GitHub rules: 1-39 letters, digits or -, no leading or trailing -"); + } + if (!validRepo) { + return reject("repo must follow GitHub rules: 1-100 characters of letters, digits, _, . or -", { + owner, + }); + } + if (kind === null) { + return reject("kind must be graph or node", { owner, repo }); + } + if (!SLUG_RE.test(slug)) { + return reject("slug must be 1-48 characters of a-z, 0-9 or -", { owner, repo, kind }); + } + return finish(false, { owner, repo, kind, slug }, ref); +} + +/** + * The router: one case per route shape, keyed by the segment count after + * `/i`. A later spec adds its route by adding a case here; `fetch` never + * needs to change. + */ +function route(segments: string[], ref: string | null): Parsed { + if (segments.length === 3) { + return official(segments[1], segments[2], ref); + } + if (segments.length === 5) { + return community(segments[1], segments[2], segments[3], segments[4], ref); + } + return reject(`unknown route; ${onOneLine(USAGE)}`); +} + +/** + * Parse a request path and `?ref=` value into a target, a rejection, or the + * bare `/i/` landing (a 404 whose body is the usage text). + */ +export function parseTarget(pathname: string, ref: string | null): Parsed { + const segments = splitPath(pathname); + if (segments.length === 0 || (segments.length === 1 && segments[0] === "i")) { + return reject("no install target", {}, 404); + } + if (segments[0] !== "i") { + return reject(`unknown route; ${onOneLine(USAGE)}`); + } + return route(segments, ref); +} + +/** Where the document really lives. This is the only URL the Worker hands out. */ +export function buildUrl(target: Target): string { + const prefix = target.official ? OFFICIAL_PREFIX : ""; + return `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${target.ref}/${prefix}${target.kind}s/${target.slug}/${target.kind}.json`; +} + +const CANOPY_RE = /^canopy\/([0-9A-Za-z][0-9A-Za-z.+-]{0,31})/; + +/** The Canopy version a Canopy client announced, or `""` for anything else. */ +export function canopyVersion(userAgent: string | null): string { + const match = CANOPY_RE.exec(userAgent ?? ""); + return match?.[1] ?? ""; +} + +/** + * Coarse client class. Deliberately not the raw user-agent: the only question + * worth answering is whether Canopy, a script or a person asked. + */ +export function clientClass(userAgent: string | null): Client { + if (canopyVersion(userAgent) !== "") { + return "canopy"; + } + const ua = (userAgent ?? "").toLowerCase(); + if (ua.includes("curl")) { + return "curl"; + } + if (ua.includes("mozilla")) { + return "browser"; + } + return "other"; +} + +/** The Analytics Engine index: one string naming exactly what was asked for. */ +export function indexKey(parts: Parts): string { + return [parts.owner, parts.repo, parts.kind, parts.slug].join("/"); +} + +/** The blob that says which document was asked for, without repeating owner/repo. */ +export function documentKey(parts: Parts): string { + return `${parts.owner}/${parts.repo}/${parts.slug}`; +} diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts new file mode 100644 index 0000000..ea2c23a --- /dev/null +++ b/worker/test/index.test.ts @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +import { SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import worker, { type DataPoint, type Env } from "../src/index.js"; +import { SLUG_PATTERN, SLUG_RE } from "../src/lib.js"; + +const ORIGIN = "https://graphs.univerlab.org"; + +const official = (kind: string, slug: string, ref = "HEAD"): string => + `https://raw.githubusercontent.com/UniverLab/graphs/${ref}/registry/${kind}s/${slug}/${kind}.json`; + +const community = (owner: string, repo: string, kind: string, slug: string, ref = "HEAD"): string => + `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${kind}s/${slug}/${kind}.json`; + +interface Options { + ua?: string | null; + country?: string; + env?: Env; +} + +function request(path: string, options: Options = {}): Request { + const headers = new Headers(); + if (options.ua !== null) { + headers.set("user-agent", options.ua ?? "curl/8.5.0"); + } + const req = new Request(new URL(path, ORIGIN), { headers }); + if (options.country) { + Object.defineProperty(req, "cf", { value: { country: options.country }, configurable: true }); + } + return req; +} + +function call(path: string, options: Options = {}): Response { + return worker.fetch(request(path, options), options.env ?? {}); +} + +/** An env whose Analytics Engine binding records what the Worker sends it. */ +function counting(points: DataPoint[]): Env { + return { + INSTALLS: { + writeDataPoint: (point: DataPoint): void => { + points.push(point); + }, + }, + }; +} + +describe("redirects", () => { + it("sends an official graph to the registry layout on GitHub", () => { + const response = call("/i/graph/hello-graph"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph")); + }); + + it("sends an official node to the registry layout on GitHub", () => { + const response = call("/i/node/hello-node"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("node", "hello-node")); + }); + + it("serves the same redirect through the deployed Worker", async () => { + const response = await SELF.fetch(`${ORIGIN}/i/graph/hello-graph`, { redirect: "manual" }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph")); + }); + + it("sends a community node to the repository without the registry prefix", () => { + const response = call("/i/acme/things/node/cool-node"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(community("acme", "things", "node", "cool-node")); + }); + + it("sends a community graph to the repository without the registry prefix", () => { + const response = call("/i/acme/things/graph/flow"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(community("acme", "things", "graph", "flow")); + }); + + it("honours ?ref= for a tag", () => { + const response = call("/i/graph/hello-graph?ref=v1.2.0"); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph", "v1.2.0")); + }); + + it("honours ?ref= for a branch containing a slash", () => { + const response = call("/i/acme/things/node/cool-node?ref=release/1.x"); + expect(response.headers.get("location")).toBe( + community("acme", "things", "node", "cool-node", "release/1.x"), + ); + }); + + it("tolerates a trailing slash", () => { + const response = call("/i/graph/hello-graph/"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph")); + }); + + it("never returns the document itself", () => { + const response = call("/i/graph/hello-graph"); + expect(response.body).toBeNull(); + expect(response.headers.get("content-type")).toBeNull(); + }); +}); + +describe("bad requests", () => { + const cases: Array<{ path: string; reason: string }> = [ + { path: "/i/preset/hello-graph", reason: "kind must be graph or node" }, + { path: "/i/graph/Hello", reason: "slug must be 1-48 characters of a-z, 0-9 or -" }, + { path: "/i/graph/hello_node", reason: "slug must be 1-48 characters of a-z, 0-9 or -" }, + { path: `/i/graph/${"a".repeat(49)}`, reason: "slug must be 1-48 characters of a-z, 0-9 or -" }, + { path: "/i/graph", reason: "unknown route" }, + { path: "/i/a/b/c", reason: "unknown route" }, + { path: "/i/graph/hello-graph/extra/route/more", reason: "unknown route" }, + { path: "/health", reason: "unknown route" }, + { path: "/i/-acme/things/graph/flow", reason: "owner must follow GitHub rules" }, + { path: "/i/acme/re@po/graph/flow", reason: "repo must follow GitHub rules" }, + { path: "/i/acme/things/preset/flow", reason: "kind must be graph or node" }, + { path: "/i/acme/things/graph/Flow", reason: "slug must be 1-48 characters of a-z, 0-9 or -" }, + { path: "/i/graph/hello-graph?ref=", reason: "ref must be a branch, tag or commit sha" }, + { path: "/i/graph/hello-graph?ref=-main", reason: "ref must be a branch, tag or commit sha" }, + { + path: `/i/graph/hello-graph?ref=${"x".repeat(129)}`, + reason: "ref must be a branch, tag or commit sha", + }, + ]; + + it("rejects every invalid shape with a 400 and a one-line reason", async () => { + for (const { path, reason } of cases) { + const response = call(path); + expect(response.status, path).toBe(400); + expect(response.headers.get("content-type"), path).toBe("text/plain; charset=utf-8"); + const text = await response.text(); + expect(text, path).toContain(reason); + expect(text.trimEnd().split("\n"), path).toHaveLength(1); + } + }); + + it("rejects through the deployed Worker too", async () => { + const response = await SELF.fetch(`${ORIGIN}/i/preset/hello-graph`, { redirect: "manual" }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("kind must be graph or node"); + }); +}); + +describe("bare install path", () => { + it.each(["/i/", "/i"])("answers %s with a 404 and the usage text", async (path) => { + const response = call(path); + expect(response.status).toBe(404); + const text = await response.text(); + expect(text).toContain("/i//"); + expect(text).toContain("/i////"); + expect(text).toContain("graph or node"); + }); +}); + +describe("counting", () => { + it("writes one point for a redirect", () => { + const points: DataPoint[] = []; + const response = call("/i/graph/hello-graph", { ua: "curl/8.5.0", env: counting(points) }); + expect(response.status).toBe(302); + expect(points).toHaveLength(1); + expect(points[0]).toEqual({ + indexes: ["UniverLab/graphs/graph/hello-graph"], + blobs: ["graph", "UniverLab/graphs/hello-graph", "curl", "", "", "redirect"], + doubles: [1], + }); + }); + + it("records the Canopy version and country without the raw user-agent", () => { + const points: DataPoint[] = []; + call("/i/node/hello-node", { + ua: `canopy/1.7.2 ${"x".repeat(200)}`, + country: "CO", + env: counting(points), + }); + expect(points).toHaveLength(1); + const blobs = points[0]?.blobs ?? []; + expect(blobs[2]).toBe("canopy"); + expect(blobs[3]).toBe("1.7.2"); + expect(blobs[4]).toBe("CO"); + expect(blobs.join(" ")).not.toContain("x".repeat(10)); + }); + + it("tells browsers and unknown clients from scripts", () => { + const cases: Array<[string | null, string]> = [ + ["Mozilla/5.0 (Macintosh) Safari/605", "browser"], + ["wget/1.21", "other"], + [null, "other"], + ]; + for (const [ua, client] of cases) { + const points: DataPoint[] = []; + call("/i/graph/hello-graph", { ua, env: counting(points) }); + expect(points[0]?.blobs?.[2], String(ua)).toBe(client); + expect(points[0]?.blobs?.[3], String(ua)).toBe(""); + } + }); + + it("writes a bad-request point with the parts it could read", () => { + const points: DataPoint[] = []; + const response = call("/i/graph/Not_A_Slug", { env: counting(points) }); + expect(response.status).toBe(400); + expect(points).toEqual([ + { + indexes: ["UniverLab/graphs/graph/?"], + blobs: ["graph", "UniverLab/graphs/?", "curl", "", "", "bad-request"], + doubles: [1], + }, + ]); + }); + + it("writes a bad-request point for the bare /i/", () => { + const points: DataPoint[] = []; + const response = call("/i/", { env: counting(points) }); + expect(response.status).toBe(404); + expect(points).toEqual([ + { indexes: ["?/?/?/?"], blobs: ["?", "?/?/?", "curl", "", "", "bad-request"], doubles: [1] }, + ]); + }); + + it("writes one point per request, never two", () => { + const points: DataPoint[] = []; + for (const path of ["/i/graph/hello-graph", "/i/graph/hello-graph?ref=main", "/health"]) { + call(path, { env: counting(points) }); + } + expect(points).toHaveLength(3); + }); +}); + +describe("analytics never changes the answer", () => { + it("still redirects when the binding throws", () => { + const env: Env = { + INSTALLS: { + writeDataPoint: (): void => { + throw new Error("analytics engine is down"); + }, + }, + }; + const response = call("/i/graph/hello-graph", { env }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph")); + }); + + it("still redirects when the binding is missing", () => { + const response = call("/i/graph/hello-graph", { env: {} }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(official("graph", "hello-graph")); + }); + + it("still answers bad requests when the binding throws", async () => { + const env: Env = { + INSTALLS: { + writeDataPoint: (): void => { + throw new Error("analytics engine is down"); + }, + }, + }; + const response = call("/i/preset/hello-graph", { env }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("kind must be graph or node"); + }); +}); + +describe("slug rule", () => { + it("matches the literal pattern copied from src/slug.ts", () => { + expect(SLUG_RE.source).toBe(SLUG_PATTERN); + expect(SLUG_RE.test("hello-graph")).toBe(true); + expect(SLUG_RE.test("hello_graph")).toBe(false); + expect(SLUG_RE.test("Hello")).toBe(false); + }); +}); diff --git a/worker/vitest.config.ts b/worker/vitest.config.ts new file mode 100644 index 0000000..c0c0db3 --- /dev/null +++ b/worker/vitest.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + include: ["worker/test/**/*.test.ts"], + poolOptions: { + workers: { + // Single-file Worker: the pool loads this config, so tests speak to + // the same code and bindings `wrangler deploy` will ship. + wrangler: { configPath: "./wrangler.toml" }, + }, + }, + }, +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..de163f4 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,32 @@ +# graphs.univerlab.org — install redirector +# +# One Worker, one job: answer Canopy's install request with a 302 to the +# document on GitHub, and count the request in aggregate. The Worker never +# proxies the document, so GitHub stays the download origin and the trust +# anchor (contract: Intelligence 778242cb). +name = "canopy-graphs" +main = "src/index.ts" +compatibility_date = "2026-10-26" + +# Inlined on purpose: the account has a single Worker account, and the deploy +# workflow then needs exactly one secret (CLOUDFLARE_API_TOKEN). +account_id = "d7b81bdd721f503968523dd903d967be" + +# Aggregate counting. Analytics Engine is the right store: writes are +# fire-and-forget (no latency added to the redirect), there is no database to +# provision, and it is built for high-volume, low-value events. KV would need +# read-modify-write and rate-limits at 1 write/sec/key; D1 would put a SQL +# write in the hot path of every install. +# +# REQUIRED FIRST: Analytics Engine must be enabled once on the account, at +# dash.cloudflare.com → Storage & databases → Analytics Engine (NOT under +# Compute/Workers, despite what the error message links to). Until it is, a +# deploy that declares this binding fails with `code: 10089`, whatever the +# token's permissions. The dataset itself needs no provisioning — it is +# created on the first write; the dashboard's "Create Dataset" button is not +# needed. +# +# The binding is optional in code: `wrangler dev` without it still serves. +[[analytics_engine_datasets]] +binding = "INSTALLS" +dataset = "canopy_graph_installs" From a45e0932606f9ce5317b9af0e204d91333f46196 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 17:47:45 -0500 Subject: [PATCH 005/180] feat(worker): redirect graph and node installs to GitHub and count them (reviewer 2 fixes) --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 5dbca59..3d317d2 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,12 @@ must equal the document's slug-safe `name` (`^[a-z0-9-]{1,48}$`). The schemas in `schema/` are the single contract the worker and Canopy's `graph validate` agree on — import them, never copy them. + +## Worker deploy + +The install redirector lives in `worker/` (Cloudflare Worker +`canopy-graphs`, behind `graphs.univerlab.org`). Deploys are manual only +(`.github/workflows/deploy.yml`, `workflow_dispatch`). Before the first +deploy, follow the one-time setup in [worker/README.md](worker/README.md): +enable Analytics Engine on the account, bind the custom domain, and set +`CLOUDFLARE_API_TOKEN`. From 4c88d724b9a1ec1b31e9614f7c1a7a753ea09207 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 18:19:03 -0500 Subject: [PATCH 006/180] feat(worker): discover official and community graphs through GitHub with edge caching --- worker/README.md | 29 +++- worker/src/analytics.ts | 216 +++++++++++++++++++++++++ worker/src/api.ts | 260 +++++++++++++++++++++++++++++++ worker/src/documents.ts | 259 ++++++++++++++++++++++++++++++ worker/src/github.ts | 186 ++++++++++++++++++++++ worker/src/index.ts | 28 +++- worker/src/json.ts | 79 ++++++++++ worker/src/lib.ts | 21 ++- worker/src/official.ts | 158 +++++++++++++++++++ worker/src/search.ts | 210 +++++++++++++++++++++++++ worker/src/validate.ts | 41 +++++ worker/test/analytics.test.ts | 115 ++++++++++++++ worker/test/api.test.ts | 187 ++++++++++++++++++++++ worker/test/cache.test.ts | 54 +++++++ worker/test/documents-fixture.ts | 27 ++++ worker/test/fakes.ts | 125 +++++++++++++++ worker/test/github.test.ts | 174 +++++++++++++++++++++ worker/test/index.test.ts | 2 +- worker/test/official.test.ts | 88 +++++++++++ worker/test/search.test.ts | 184 ++++++++++++++++++++++ worker/wrangler.toml | 6 + 21 files changed, 2440 insertions(+), 9 deletions(-) create mode 100644 worker/src/analytics.ts create mode 100644 worker/src/api.ts create mode 100644 worker/src/documents.ts create mode 100644 worker/src/github.ts create mode 100644 worker/src/json.ts create mode 100644 worker/src/official.ts create mode 100644 worker/src/search.ts create mode 100644 worker/src/validate.ts create mode 100644 worker/test/analytics.test.ts create mode 100644 worker/test/api.test.ts create mode 100644 worker/test/cache.test.ts create mode 100644 worker/test/documents-fixture.ts create mode 100644 worker/test/fakes.ts create mode 100644 worker/test/github.test.ts create mode 100644 worker/test/official.test.ts create mode 100644 worker/test/search.test.ts diff --git a/worker/README.md b/worker/README.md index 2a7be31..fbd7f93 100644 --- a/worker/README.md +++ b/worker/README.md @@ -50,6 +50,27 @@ person. The client class is a coarse bucket, not the raw header. The write is fire-and-forget inside a `try/catch`: if the binding is missing (local dev) or Analytics Engine is down, the redirect still happens. +## JSON API (the catalogue's read surface) + +Pages and agents read a JSON view of the catalogue; GitHub is still the +database, the Cache API is still the only state: + +| Route | Answer | +|---|---| +| `GET /api/official` | `{graphs, nodes}` — the `registry/` listing | +| `GET /api/search?q=&sort=top\|new&page=` | `{items, q, sort, page, total, truncated}` — `topic:canopy-graph` plus free text | +| `GET /api/g///` | one graph document, validated JSON attached | +| `GET /api/n///` | one node document, validated JSON attached | + +Every answer carries `access-control-allow-origin: *` (GET only, OPTIONS → +204) and short cache-control (60 s for search, 300 s otherwise). Errors are +typed JSON bodies — `{kind, message, status, retryAfter?}` with kinds +`rate-limit` (429), `not-found`/`invalid-document` (404), `upstream` (502), +`invalid-path`/`invalid-query` (400) — so nothing the Worker fails at can +turn into a 500 for an agent to choke on. `downloads` (90-day install +counts) is attached when `CF_ACCOUNT_ID` + `CF_ANALYTICS_TOKEN` are set, and +absent when not: counts are never zero-filled. + ## Tests ```sh @@ -58,8 +79,12 @@ npm run test:worker # worker/ suite, in the Workers runtime via Miniflare The suite covers both route shapes, `?ref=`, every 400 case, the `/i/` usage page, the data point written for each outcome, and that a throwing or missing -binding still serves the redirect. `npm test` runs it together with the -document validator suite. +binding still serves the redirect, plus the API surface above: official +listing, search with text and both orderings, invalid documents dropped, a +cache hit on a second call, rate limits surfaced as typed errors, and counts +present and absent. `npm test` runs it together with the document validator +suite. The API tests use injected fakes (`worker/test/fakes.ts`), +never stubbed globals. ## One-time setup (before the first deploy) diff --git a/worker/src/analytics.ts b/worker/src/analytics.ts new file mode 100644 index 0000000..b92fd2d --- /dev/null +++ b/worker/src/analytics.ts @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Install counts from Cloudflare's Analytics Engine for the catalogue. + * + * The install redirector writes one aggregate data point per install; this + * module reads them back with the Analytics Engine SQL API and returns a + * map of `owner/repo/kind/slug` to the 90-day count. Without + * `CF_ACCOUNT_ID` and `CF_ANALYTICS_TOKEN` there is simply no data: the + * map is null and documents carry no `downloads` field, never a zero. The + * SQL call is a POST, but its result is cached under a synthetic GET key + * for 60 minutes like every other upstream answer. + */ +import { TTL, type Log, type CacheLike } from "./github.js"; +import type { Doc } from "./documents.js"; +import { isNumber, isRecord, isString } from "./json.js"; + +/** How far back install counts reach. */ +export const DOWNLOAD_WINDOW_DAYS = 90; + +/** The synthetic GET key the SQL result is cached under. */ +export const ANALYTICS_CACHE_URL = + "https://canopy-graphs.analytics.internal/install-counts-90d"; + +export interface AnalyticsOptions { + fetchImpl?: typeof fetch; + cache?: CacheLike; + accountId?: string; + token?: string; + log?: Log; +} + +export type Downloads = Map; + +export interface AnalyticsVolume { + /** The 90-day install counts, or null when analytics is not configured. */ + volume(): Promise; +} + +interface Rows { + data?: unknown; + result?: unknown; +} + +/** Parse whatever rows shape the SQL API handed back into key/count pairs. */ +function rowsOf(body: unknown): Array<{ key: string; count: number }> { + const rows: unknown[] = Array.isArray(body) + ? body + : isRecord(body) && Array.isArray((body as Rows).data) + ? ((body as Rows).data as unknown[]) + : isRecord(body) && Array.isArray((body as Rows).result) + ? ((body as Rows).result as unknown[]) + : []; + return rows.flatMap((row) => { + if (!isRecord(row)) { + return []; + } + const key = row["index"] ?? row["k"] ?? row["_index"]; + const count = row["count"] ?? row["n"] ?? row["_sample_interval_sum"]; + if (!isString(key) || !isNumber(count) || !Number.isFinite(count)) { + return []; + } + return [{ key, count }]; + }); +} + +const QUERY = + `SELECT index1 AS index, SUM(_sample_interval) AS count ` + + `FROM canopy_graph_installs ` + + `WHERE timestamp > NOW() - INTERVAL '${DOWNLOAD_WINDOW_DAYS}' DAY GROUP BY index1 ORDER BY count DESC`; + +/** Build the collector. Always returns a handle; config decides if it has data. */ +export function createAnalytics(options: AnalyticsOptions): AnalyticsVolume { + const log: Log = options.log ?? (() => undefined); + const configured = + options.accountId !== undefined && options.accountId !== "" && + options.token !== undefined && options.token !== ""; + + async function volume(): Promise { + if (!configured) { + return null; + } + const cache: CacheLike | undefined = options.cache ?? maybeDefaultCache(); + const request = new Request(ANALYTICS_CACHE_URL, { method: "GET" }); + if (cache !== undefined) { + try { + const cached = await cache.match(request); + if (cached !== undefined) { + const pairs = volumesFromCacheBody(await cached.json() as unknown); + if (pairs !== null) { + return pairs; + } + log("analytics: unreadable cache entry, refreshing"); + } + } catch { + // an unreadable cache falls through to the SQL call + } + } + + const map = await sqlVolume(options, log); + if (map === null) { + return null; + } + if (cache !== undefined) { + try { + const stored = new Response(JSON.stringify(Array.from(map.entries())), { + headers: { + "content-type": "application/json", + "cache-control": `public, max-age=${TTL.analytics}`, + }, + }); + await cache.put(request, stored); + } catch { + // caching is an optimisation, never the answer + } + } + return map; + } + + return { volume }; +} + +/** The one SQL call, POSTed to the Analytics Engine SQL API. */ +async function sqlVolume( + options: AnalyticsOptions, + log: Log, +): Promise { + let response: Response; + try { + response = await (options.fetchImpl ?? fetch)( + `https://api.cloudflare.com/client/v4/accounts/${options.accountId}/analytics_engine/sql`, + { + method: "POST", + headers: { + "authorization": `Bearer ${options.token}`, + "content-type": "text/plain", + }, + body: QUERY, + }, + ); + } catch (error) { + log(`analytics: sql call failed: ${String(error)}`); + return null; + } + if (!response.ok) { + log(`analytics: sql answered ${response.status}`); + return null; + } + let body: unknown; + try { + body = await response.json(); + } catch { + log("analytics: sql answered unreadable json"); + return null; + } + return mapOf(body); +} + +/** `caches.default` when the runtime has one, undefined otherwise. */ +function maybeDefaultCache(): CacheLike | undefined { + const runtime = globalThis as unknown as { caches?: { default?: CacheLike } }; + return runtime.caches?.default; +} + +function mapOf(body: unknown): Downloads { + const map = new Map(); + for (const { key, count } of rowsOf(body)) { + map.set(key, count); + } + return map; +} + +/** The key one install was indexed under: `owner/repo/kind/slug`. */ +export function downloadKey(doc: Doc): string { + return `${doc.repo}/${doc.kind}/${doc.slug}`; +} + +/** + * Attach 90-day install counts to documents. Absent analytics (or a + * document nobody installed) leave `downloads` off the document entirely; + * counts are never zero-filled. + */ +export function attachDownloads(docs: Doc[], volumes: Downloads | null): Doc[] { + if (volumes === null) { + return docs; + } + return docs.map((doc) => { + const downloads = volumes.get(downloadKey(doc)); + if (downloads === undefined) { + return doc; + } + return { ...doc, downloads }; + }); +} + +/** + * Read the cached volumes back: the collector stores a JSON array of + * `[key, count]` pairs, so a cache hit round-trips without a SQL call. + */ +export function volumesFromCacheBody(value: unknown): Downloads | null { + if (!Array.isArray(value)) { + return null; + } + let ok = true; + const map = new Map(); + for (const entry of value) { + if (!Array.isArray(entry) || entry.length !== 2 || !isString(entry[0]) || !isNumber(entry[1])) { + ok = false; + continue; + } + map.set(entry[0], entry[1]); + } + if (!ok) { + return null; + } + return map; +} diff --git a/worker/src/api.ts b/worker/src/api.ts new file mode 100644 index 0000000..bda1721 --- /dev/null +++ b/worker/src/api.ts @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The JSON API for graphs.univerlab.org: the contract the pages and + * third-party agents consume. + * + * GET /api/official -> { graphs, nodes } + * GET /api/search?q=&sort=top|new&page= -> { items, q, sort, page, total, truncated } + * GET /api/g/// -> one graph document + * GET /api/n/// -> one node document + * + * Every answer is CORS-open for GET, every error is a typed JSON body + * (never an HTML stack trace, never a 500 the pages cannot show). `index.ts` + * hands `/api/*` here first; the install redirector keeps its routes. + */ +import { attachDownloads, createAnalytics } from "./analytics.js"; +import { createGitHub, isApiError, type ApiError, type CacheLike, type GitHub, type Log } from "./github.js"; +import { SLUG_RE, OWNER_RE, REPO_RE, isOfficialRepo } from "./lib.js"; +import { documentDirectory, loadDocument } from "./documents.js"; +import { listOfficial, loadOfficialDocument } from "./official.js"; +import { searchCommunity, type SearchSort } from "./search.js"; + +/** Environment the API reads: three optional values, no bindings here. */ +export interface ApiEnv { + GITHUB_TOKEN?: string; + CF_ACCOUNT_ID?: string; + CF_ANALYTICS_TOKEN?: string; +} + +/** Dependency injection for tests; production defaults. */ +export interface ApiDeps { + fetchImpl?: typeof fetch; + cache?: CacheLike; + log?: Log; +} + +const NO_LOG: Log = () => undefined; + +function corsHeaders(): Record { + return { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, OPTIONS", + }; +} + +function jsonResponse(payload: unknown, status: number, maxAge: number): Response { + return new Response(JSON.stringify(payload, null, 2) + "\n", { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": `public, max-age=${maxAge}`, + ...corsHeaders(), + }, + }); +} + +function errorResponse(error: ApiError): Response { + return jsonResponse( + { kind: error.kind, message: error.message, status: error.status, retryAfter: error.retryAfter ?? undefined }, + error.status, + 60, + ); +} + +/** A non-API request: index.ts keeps routing it to the redirector. */ +export const NOT_API = true as const; + +function badQuery(message: string): ApiError { + return { kind: "invalid-query", message, status: 400 }; +} + +function invalidPath(message: string): ApiError { + return { kind: "invalid-path", message, status: 400 }; +} + +/** Read `q`, `sort` and `page` out of a search request, 400s when malformed. */ +function readSearchInput(url: URL): { input: { q: string; sort: SearchSort; page: number } } | ApiError { + const q = url.searchParams.get("q") ?? ""; + if (q.length > 256) { + return badQuery("q must be at most 256 characters"); + } + const sortParam = url.searchParams.get("sort") ?? "top"; + if (sortParam !== "top" && sortParam !== "new") { + return badQuery("sort must be top or new"); + } + const pageParam = url.searchParams.get("page") ?? "1"; + const page = Number.parseInt(pageParam, 10); + if (!Number.isInteger(page) || page < 1) { + return badQuery("page must be a positive integer"); + } + return { input: { q, sort: sortParam, page } }; +} + +/** Parse `/api//<...segments>`; null when the path is not a known route. */ +function readRoute(pathname: string): { verb: string; segments: string[] } | null { + const parts = pathname.replace(/^\/+|\/+$/g, "").split("/"); + if (parts[0] !== "api") { + return null; + } + return { verb: parts[1] ?? "", segments: parts.slice(2) }; +} + +/** True when owner/repo/slug all follow the rules the URLs promise. */ +function partsInvalid(owner: string, repo: string, slug: string): string | null { + if (!OWNER_RE.test(owner)) { + return "owner must follow GitHub rules"; + } + if (!REPO_RE.test(repo)) { + return "repo must follow GitHub rules"; + } + if (!SLUG_RE.test(slug)) { + return "slug must be 1-48 characters of a-z, 0-9 or -"; + } + return null; +} + +async function singleDocument( + github: GitHub, + owner: string, + repo: string, + slug: string, + log: Log, +): Promise { + if (isOfficialRepo(owner, repo)) { + const loaded = await loadOfficialDocument(github, slug, log); + if (loaded.kind === "doc") { + return jsonResponse(loaded.entry, 200, 300); + } + if (loaded.kind === "absent") { + return errorResponse({ + kind: "invalid-document", + message: `no official graph or node named ${slug}`, + status: 404, + }); + } + return errorResponse(loaded); + } + for (const kind of ["graph", "node"] as const) { + const source = { + owner, + repo, + directory: documentDirectory(owner, repo, kind, slug), + kind, + slug, + stars: null, + }; + const loaded = await loadDocument(github, source, log); + if (loaded.kind === "doc") { + return jsonResponse(loaded.entry, 200, 300); + } + if (loaded.kind !== "absent") { + return errorResponse(loaded); + } + } + return errorResponse({ + kind: "invalid-document", + message: `no graph or node named ${slug} in ${owner}/${repo}`, + status: 404, + }); +} + +/** + * The API router. Returns NOT_API for paths outside `/api/*` so index.ts + * keeps its redirect routes untouched, a JSON answer for every API request, + * and typed errors everywhere. + */ +export async function handleApi( + request: Request, + env: ApiEnv, + deps: ApiDeps = {}, +): Promise { + const url = new URL(request.url); + const route = readRoute(url.pathname); + const log = deps.log ?? NO_LOG; + + if (route === null || route.verb === "") { + return NOT_API; + } + + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + if (request.method !== "GET") { + return errorResponse({ kind: "invalid-path", message: "only GET is supported", status: 400 }); + } + + const github = createGitHub({ + fetchImpl: deps.fetchImpl, + cache: deps.cache, + token: env.GITHUB_TOKEN, + log, + }); + + switch (route.verb) { + + case "official": { + if (route.segments.length !== 0) { + return errorResponse(invalidPath("usage: GET /api/official")); + } + const listing = await listOfficial(github, log); + if (isApiError(listing)) { + return errorResponse(listing); + } + const volumes = await createAnalytics({ + fetchImpl: deps.fetchImpl, + cache: deps.cache, + accountId: env.CF_ACCOUNT_ID, + token: env.CF_ANALYTICS_TOKEN, + log, + }).volume(); + return jsonResponse( + { + graphs: attachDownloads(listing.graphs, volumes), + nodes: attachDownloads(listing.nodes, volumes), + }, + 200, + 300, + ); + } + case "search": { + if (route.segments.length !== 0) { + return errorResponse(invalidPath("usage: GET /api/search?q=&sort=top|new&page=")); + } + const parsed = readSearchInput(url); + if ("kind" in parsed) { + return errorResponse(parsed); + } + const outcome = await searchCommunity(github, parsed.input, log); + if (isApiError(outcome)) { + return errorResponse(outcome); + } + const volumes = await createAnalytics({ + fetchImpl: deps.fetchImpl, + cache: deps.cache, + accountId: env.CF_ACCOUNT_ID, + token: env.CF_ANALYTICS_TOKEN, + log, + }).volume(); + return jsonResponse( + { ...outcome, items: attachDownloads(outcome.items, volumes) }, + 200, + 60, + ); + } + case "g": + case "n": { + if (route.segments.length !== 3) { + return errorResponse(invalidPath(`usage: GET /api/${route.verb}///`)); + } + const [owner, repo, slug] = route.segments; + const invalid = partsInvalid(owner, repo, slug); + if (invalid !== null) { + return errorResponse(invalidPath(invalid)); + } + return singleDocument(github, owner, repo, slug, log); + } + default: + return errorResponse(invalidPath("unknown API route")); + } +} + diff --git a/worker/src/documents.ts b/worker/src/documents.ts new file mode 100644 index 0000000..bef89d9 --- /dev/null +++ b/worker/src/documents.ts @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Folder-to-document composition for the catalogue. + * + * GitHub's contents API, a repo's git tree or a search hit all say where a + * document *might* live: `/s//.json` plus a + * README. This module turns that into a validated `Doc`, the shape the + * pages render, using the fewest GitHub calls possible (the README is only + * fetched once the document validated) and dropping anything invalid with a + * note in the log instead of an error. + */ +import type { DocumentKind, JsonValue } from "../../src/schema.js"; +import type { ApiError, GitHub } from "./github.js"; +import { isApiError } from "./github.js"; +import { decodeBase64, isArray, isNumber, isRecord, isString, parseJson, stringField } from "./json.js"; +import { isValidDocument } from "./validate.js"; +import { isOfficialRepo } from "./lib.js"; + +/** One rendered row: no HTML, no storage, just the fields the pages show. */ +export interface Doc { + kind: DocumentKind; + slug: string; + name: string; + description: string; + readme: string; + /** Graph: the number of nodes. Node: 1. */ + nodeCount: number; + repo: string; + /** GitHub stars, or null when nobody told us. Never zero-filled. */ + stars: number | null; + /** Install counts from the Analytics Engine, present only when configured. */ + downloads?: number; +} + +/** A validated document plus its rendered shape (for the SVG pages later). */ +export interface CatalogEntry { + doc: Doc; + document: JsonValue; +} + +/** Where one document lives inside its repository. */ +export interface DocumentSource { + owner: string; + repo: string; + /** Directory holding the document, e.g. `registry/graphs/hello-graph`. */ + directory: string; + kind: DocumentKind; + slug: string; + /** Stars when the caller already knows them (a search hit, the repo call). */ + stars: number | null; +} + +/** The document filename, by kind, following the publish convention. */ +export function documentFile(kind: DocumentKind): string { + return kind === "graph" ? "graph.json" : "node.json"; +} + +/** `registry/graphs` when the repository is the official one, else ``. */ +export function prefixFor(owner: string, repo: string): string { + return isOfficialRepo(owner, repo) ? "registry/" : ""; +} + +/** The document folder for a slug, given the repository's prefix. */ +export function documentDirectory( + owner: string, + repo: string, + kind: DocumentKind, + slug: string, +): string { + const prefix = prefixFor(owner, repo); + return `${prefix}${kind}s/${slug}`; +} + +function contentsUrl(owner: string, repo: string, path: string, ref: string): string { + const clean = path.replace(/^\/+/, ""); + const question = ref === "" ? "" : `?ref=${encodeURIComponent(ref)}`; + return `https://api.github.com/repos/${owner}/${repo}/contents/${clean}${question}`; +} + +function rawUrl(owner: string, repo: string, path: string, ref: string): string { + const branch = ref === "" ? "HEAD" : ref; + return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`; +} + +/** An entry from a contents listing or a git tree we only need paths of. */ +export interface TreeFile { + path: string; + type?: string; +} + +/** Read the visible paths out of a git-trees response body. */ +export function pathsFromTree(value: unknown): TreeFile[] { + if (!isRecord(value)) { + return []; + } + const tree = value["tree"]; + if (!isArray(tree)) { + return []; + } + return tree.flatMap((entry) => { + if (!isRecord(entry) || !isString(entry["path"])) { + return []; + } + return [{ path: entry["path"], type: isString(entry["type"]) ? entry["type"] : undefined }]; + }); +} + +/** + * The document folders a tree implies. A path counts when it is a + * document file next to its folder: `graphs//graph.json` (and + * `nodes/…/node.json` likewise, matching the publish rule registry follows). + */ +export function sourcesFromTree( + owner: string, + repo: string, + files: TreeFile[], +): DocumentSource[] { + const documentRe = /^(graphs|nodes)\/([a-z0-9-]{1,48})\/(?:graph|node)\.json$/; + const readmeRe = /^(?:graphs|nodes)\/([a-z0-9-]{1,48})\/readme\.md$/i; + return files.flatMap((file) => { + const match = documentRe.exec(file.path); + if (match === null) { + return []; + } + const kind: DocumentKind = match[1] === "graphs" ? "graph" : "node"; + return [{ owner, repo, directory: `${match[1]}/${match[2]}`, kind, slug: match[2], stars: null }]; + }); +} + +type Text = + | { kind: "text"; text: string } + | { kind: "absent" } + | ApiError; + +/** + * Read one file as text: the contents API first (inline base64), falling + * back to the raw URL when the content is absent (files over 1 MB) or the + * path looks gone at the contents API but exists raw. Cached per class: + * contents 30 minutes, raw 30 minutes. + */ +async function readText( + github: GitHub, + owner: string, + repo: string, + path: string, + ref: string, + log: (message: string) => void, +): Promise { + const file = await github.get(contentsUrl(owner, repo, path, ref), "contents"); + if (isApiError(file)) { + if (file.kind !== "not-found") { + return file; + } + log(`documents: ${path} absent at the contents API, trying raw`); + return rawText(github, owner, repo, path, ref); + } + const body = await file.json() as unknown; + if (isRecord(body) && body["content"] !== undefined && body["content"] !== null) { + const decoded = decodeBase64(body["content"]); + if (decoded !== null) { + return { kind: "text", text: decoded }; + } + } + log(`documents: ${path} has no inline content, trying raw`); + return rawText(github, owner, repo, path, ref); +} + +/** The raw fallback: same file straight from raw.githubusercontent.com. */ +async function rawText( + github: GitHub, + owner: string, + repo: string, + path: string, + ref: string, +): Promise { + const raw = await github.get(rawUrl(owner, repo, path, ref), "raw"); + if (isApiError(raw)) { + if (raw.kind === "not-found") { + return { kind: "absent" }; + } + return raw; + } + return { kind: "text", text: await raw.text() }; +} + +/** The count of nodes in a graph document (0 when malformed); a node is 1. */ +function nodeCountOf(kind: DocumentKind, document: JsonValue): number { + if (kind === "node") { + return 1; + } + if (!isRecord(document) || !isArray(document["nodes"])) { + return 0; + } + return document["nodes"].length; +} + +/** + * Load, validate and render one document. Returns `absent` for the ordinary + * misses (no folder, no file, README only missing), a typed error when + * GitHub itself is the problem (rate limit, upstream), and a note in the + * log for every dropped document. + */ +export async function loadDocument( + github: GitHub, + source: DocumentSource, + log: (message: string) => void, +): Promise< + | { kind: "doc"; entry: CatalogEntry } + | { kind: "absent" } + | ApiError +> { + const { owner, repo, kind, slug } = source; + const jsonPath = `${source.directory}/${documentFile(kind)}`; + const documentText = await readText(github, owner, repo, jsonPath, "", log); + if (documentText.kind === "absent") { + log(`documents: dropped ${owner}/${repo} ${jsonPath}: missing document file`); + return { kind: "absent" }; + } + if (documentText.kind !== "text") { + return documentText; + } + const parsed = parseJson(documentText.text); + if (!parsed.ok || !isRecord(parsed.value) || !isValidDocument(kind, parsed.value)) { + log(`documents: dropped ${owner}/${repo} ${jsonPath}: invalid document`); + return { kind: "absent" }; + } + const document = parsed.value; + const readmePath = `${source.directory}/README.md`; + const readme = await readText(github, owner, repo, readmePath, "", log); + const readmeText = readme.kind === "text" ? readme.text : ""; + if (readme.kind === "absent") { + log(`documents: ${owner}/${repo} ${jsonPath} listed without a README`); + } + return { + kind: "doc", + entry: { + document, + doc: { + kind, + slug, + name: stringField(document, "name") || slug, + description: stringField(document, "description"), + readme: readmeText, + nodeCount: nodeCountOf(kind, document), + repo: `${owner}/${repo}`, + stars: source.stars, + }, + }, + }; +} + +/** Read `stargazers_count` from a `/repos/...` body, null when absent. */ +export function starsFromRepository(value: unknown): number | null { + if (isRecord(value)) { + const stars = value["stargazers_count"]; + return isNumber(stars) ? stars : null; + } + return null; +} diff --git a/worker/src/github.ts b/worker/src/github.ts new file mode 100644 index 0000000..0a64677 --- /dev/null +++ b/worker/src/github.ts @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * GitHub transport for graphs.univerlab.org. + * + * Every call is a GET wrapped in the Cache API: the exact GitHub URL is the + * cache key, only status 200 is stored, and the time-to-live depends on what + * the URL is for (search 10 minutes, contents and raw files 30 minutes). + * Lookup happens before the fetch, so a warm cache keeps serving when the + * token runs out of rate limit. + * + * Errors are typed values, never exceptions: rate limiting, a missing path + * and an upstream failure all come back as an `ApiError` the pages can show. + */ + +/** Where the caller wants the result cached, which sets the time-to-live. */ +export type CacheClass = "search" | "contents" | "raw" | "analytics"; + +/** Time-to-live in seconds, per cache class. */ +export const TTL: Record = { + search: 600, + contents: 1800, + raw: 1800, + analytics: 3600, +}; + +/** User-Agent GitHub asks every client to send. */ +export const USER_AGENT = "canopy-graphs"; + +/** A sink for notes worth keeping (dropped documents, skipped repos). */ +export type Log = (message: string) => void; + +/** The smallest Cache API surface the transport needs. */ +export interface CacheLike { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} + +/** A typed catalogue error. `status` is what the API answers with. */ +export interface ApiError { + kind: "rate-limit" | "not-found" | "upstream" | "invalid-path" | "invalid-query" | "invalid-document"; + message: string; + status: number; + /** Seconds until the rate limit resets; only for `rate-limit`. */ + retryAfter?: number; +} + +/** Dot-access on an unknown value, `undefined` when not an object. */ +function field(value: unknown, key: string): unknown { + if (value !== null && typeof value === "object" && key in value) { + return (value as Record)[key]; + } + return undefined; +} + +export function isApiError(value: unknown): value is ApiError { + return field(value, "kind") !== undefined && typeof value === "object"; +} + +/** Dependency injection: tests pass fakes, the Worker passes the real ones. */ +export interface GitHubOptions { + /** Substitutable fetch; defaults to the global. */ + fetchImpl?: typeof fetch; + /** Substitutable cache; defaults to `caches.default`. */ + cache?: CacheLike; + /** Optional token, sent as a Bearer so the catalogue shares its quota fairly. */ + token?: string; + /** Where dropped or skipped things are noted; defaults to no-op. */ + log?: Log; +} + +/** The transport the catalogue modules program against. */ +export interface GitHub { + /** GET a URL, cache-first. Returns the (cached) Response or a typed error. */ + get(url: string, cacheClass: CacheClass): Promise; +} + +/** `caches.default` from the Worker runtime, found without a hard type import. */ +function defaultCache(): CacheLike { + const runtime = globalThis as unknown as { caches?: { default: CacheLike } }; + const found = runtime.caches; + if (found === undefined || found.default === undefined) { + throw new Error("the Cache API is unavailable in this runtime"); + } + return found.default; +} + +/** Seconds from now until the epoch-second limit resets, when parseable. */ +function retryAfter(resetHeader: string | null): number | undefined { + if (resetHeader === null) { + return undefined; + } + const reset = Number.parseInt(resetHeader, 10); + if (!Number.isFinite(reset)) { + return undefined; + } + const seconds = Math.max(0, Math.ceil(reset - Date.now() / 1000)); + return seconds; +} + +/** Shape a non-ok upstream response into a typed error. Never throws. */ +function errorFor(response: Response, url: string): ApiError { + const remaining = response.headers.get("x-ratelimit-remaining"); + const base = `${(new URL(url)).host}${(new URL(url)).pathname} answered ${response.status}`; + if (remaining === "0" && (response.status === 403 || response.status === 429)) { + return { + kind: "rate-limit", + message: `GitHub rate limit reached: ${base}`, + status: 429, + retryAfter: retryAfter(response.headers.get("x-ratelimit-reset")), + }; + } + if (response.status === 404 || response.status === 410) { + return { kind: "not-found", message: `Not found: ${base}`, status: 404 }; + } + return { kind: "upstream", message: `Upstream error: ${base}`, status: 502 }; +} + +/** + * Store one successful response in the cache with the class's time-to-live. + * Cache failure (quota, private mode) shrugs: the fetch already succeeded. + */ +async function store( + cache: CacheLike, + request: Request, + response: Response, + cacheClass: CacheClass, +): Promise { + try { + const headers = new Headers(response.headers); + headers.delete("set-cookie"); + headers.set("cache-control", `public, max-age=${TTL[cacheClass]}`); + // Buffer the body: the returned Response keeps its own, the clone goes to + // the cache, and neither disturbs the other. + const buffered = await response.clone().arrayBuffer(); + await cache.put( + request, + new Response(buffered, { status: response.status, headers }), + ); + } catch { + // A missing cache entry only costs the next request one fetch. + } +} + +/** Build the transport. Defaults wire to the Worker runtime; tests inject. */ +export function createGitHub(options: GitHubOptions = {}): GitHub { + const fetchImpl = options.fetchImpl ?? fetch; + const cache: CacheLike = options.cache ?? defaultCache(); + const log: Log = options.log ?? (() => undefined); + + async function get(url: string, cacheClass: CacheClass): Promise { + const request = new Request(url, { method: "GET" }); + try { + const cached = await cache.match(request); + if (cached !== undefined) { + return cached; + } + } catch { + // An unreadable cache falls through to a plain fetch. + } + + const headers = new Headers({ + "user-agent": USER_AGENT, + accept: "application/vnd.github+json", + }); + if (options.token !== undefined && options.token !== "") { + headers.set("authorization", `Bearer ${options.token}`); + } + + let response: Response; + try { + response = await fetchImpl(new Request(url, { headers })); + } catch (error) { + log(`github: fetch failed for ${url}: ${String(error)}`); + return { kind: "upstream", message: `GitHub unreachable: ${url}`, status: 502 }; + } + + if (!response.ok) { + log(`github: ${url} answered ${response.status}`); + return errorFor(response, url); + } + await store(cache, request, response, cacheClass); + return response; + } + + return { get }; +} diff --git a/worker/src/index.ts b/worker/src/index.ts index 6cf2103..c64f574 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -27,6 +27,8 @@ import { indexKey, parseTarget, } from "./lib.js"; +import { handleApi, type ApiEnv } from "./api.js"; +import { type CacheLike } from "./github.js"; /** The single Analytics Engine method this Worker uses. */ export interface DataPoint { @@ -39,11 +41,17 @@ export interface AnalyticsSink { writeDataPoint(point: DataPoint): void; } -export interface Env { +export interface Env extends ApiEnv { /** Optional: absent in local dev and in tests, and the Worker must still serve. */ INSTALLS?: AnalyticsSink; } +/** The Cache API when the runtime provides it, undefined otherwise. */ +function defaultCache(): CacheLike | undefined { + const runtime = globalThis as unknown as { caches?: { default?: CacheLike } }; + return runtime.caches?.default; +} + function textResponse(body: string, status: number): Response { return new Response(body, { status, @@ -82,9 +90,20 @@ function record(env: Env, req: Request, parts: Parts, outcome: Outcome): void { } export default { - fetch(req: Request, env: Env): Response { + fetch(req: Request, env: Env): Response | Promise { const url = new URL(req.url); - const parsed = parseTarget(url.pathname, url.searchParams.get("ref")); + if (url.pathname.startsWith("/api")) { + return handleApi(req, env, { cache: defaultCache() }).then( + (answer) => answer === true ? install(req, env, url) : answer, + ); + } + return install(req, env, url); + }, +}; + +/** The install redirector: 302 to GitHub, or a 400/404 explaining why not. */ +function install(req: Request, env: Env, url: URL): Response { + const parsed = parseTarget(url.pathname, url.searchParams.get("ref")); if (!parsed.ok) { record(env, req, parsed.rejection, "bad-request"); @@ -94,5 +113,4 @@ export default { record(env, req, parsed.target, "redirect"); return Response.redirect(buildUrl(parsed.target), 302); - }, -}; +} diff --git a/worker/src/json.ts b/worker/src/json.ts new file mode 100644 index 0000000..384dacd --- /dev/null +++ b/worker/src/json.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * JSON value helpers for the catalogue data layer. + * + * Everything here is total: no function throws, no function touches the + * network or a global. Parsing, base64 decoding and shape checks return + * values the callers can branch on, so a malformed GitHub response degrades + * into a typed error or a dropped document instead of an exception. + */ +import type { JsonValue } from "../../src/schema.js"; + +export type { JsonValue }; + +/** A parsed document: true means the text was JSON and the shapes are usable. */ +export type ParsedJson = + | { ok: true; value: JsonValue } + | { ok: false }; + +/** Parse JSON text. Never throws: invalid text simply is not ok. */ +export function parseJson(text: string): ParsedJson { + try { + return { ok: true, value: JSON.parse(text) as JsonValue }; + } catch { + return { ok: false }; + } +} + +/** True when the value is a JSON object (and not null, not an array). */ +export function isRecord( + value: unknown, +): value is { [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** True when the value is a JSON string. */ +export function isString(value: unknown): value is string { + return typeof value === "string"; +} + +/** True when the value is a JSON array. */ +export function isArray(value: unknown): value is JsonValue[] { + return Array.isArray(value); +} + +/** True when the value is a JSON number. */ +export function isNumber(value: unknown): value is number { + return typeof value === "number"; +} + +/** Read a string field from a JSON object, `""` when absent or not a string. */ +export function stringField( + record: { [key: string]: JsonValue }, + key: string, +): string { + const value = record[key]; + return isString(value) ? value : ""; +} + +/** + * Decode GitHub's base64 file content. Content arrives as standard base64 + * chunked with newlines; whitespace is stripped and characters outside the + * alphabet make the result `null` (the caller falls back to the raw URL). + */ +export function decodeBase64(encoded: unknown): string | null { + if (!isString(encoded)) { + return null; + } + const compact = encoded.replace(/\s/g, ""); + try { + const bytes = atob(compact); + const buffer = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i += 1) { + buffer[i] = bytes.charCodeAt(i); + } + return new TextDecoder().decode(buffer); + } catch { + return null; + } +} diff --git a/worker/src/lib.ts b/worker/src/lib.ts index 131bff3..5cd140b 100644 --- a/worker/src/lib.ts +++ b/worker/src/lib.ts @@ -41,6 +41,25 @@ export const OFFICIAL_REPO = "graphs"; /** Official documents live under the registry layout; community repos do not. */ const OFFICIAL_PREFIX = "registry/"; +/** + * The directory prefix inside a repository that holds the official + * catalogue documents. Reused by buildUrl and by the catalogue API. + */ +export function documentPrefix(): string { + return OFFICIAL_PREFIX; +} + +/** True when owner/repo names the official catalogue repository. */ +export function isOfficialRepo(owner: string, repo: string): boolean { + return ( + owner.toLowerCase() === OFFICIAL_OWNER.toLowerCase() && + repo.toLowerCase() === OFFICIAL_REPO.toLowerCase() + ); +} + +/** The official repository, plus the shared slug rule, for callers of buildUrl. */ +export const OFFICIAL_REPOSITORY = `${OFFICIAL_OWNER}/${OFFICIAL_REPO}`; + /** The placeholder recorded for a part a bad request did not supply. */ const UNKNOWN = "?"; @@ -207,7 +226,7 @@ export function parseTarget(pathname: string, ref: string | null): Parsed { /** Where the document really lives. This is the only URL the Worker hands out. */ export function buildUrl(target: Target): string { - const prefix = target.official ? OFFICIAL_PREFIX : ""; + const prefix = target.official ? documentPrefix() : ""; return `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${target.ref}/${prefix}${target.kind}s/${target.slug}/${target.kind}.json`; } diff --git a/worker/src/official.ts b/worker/src/official.ts new file mode 100644 index 0000000..958e3f1 --- /dev/null +++ b/worker/src/official.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The official listing for graphs.univerlab.org. + * + * UniverLab/graphs is the database: `registry/graphs/` and `registry/nodes/` + * hold one folder per document. This module lists the two directories, then + * loads each folder through the same validate-and-render path as everything + * else. Nothing is persisted beyond the Cache API. + */ +import type { DocumentKind, JsonValue } from "../../src/schema.js"; +import { + type ApiError, + type GitHub, + type Log, + isApiError, +} from "./github.js"; +import { OFFICIAL_OWNER, OFFICIAL_REPOSITORY, OFFICIAL_REPO } from "./lib.js"; +import { + type CatalogEntry, + type Doc, + documentDirectory, + loadDocument, + starsFromRepository, +} from "./documents.js"; + +/** Upper bound on documents one listing will render. */ +export const OFFICIAL_DOC_CAP = 100; + +export interface OfficialListing { + graphs: Doc[]; + nodes: Doc[]; +} + +/** A directory from a GitHub contents listing response. */ +interface ListedDir { + directory: string; +} + +/** The dirs in a contents listing; anything unreadable simply contributes none. */ +function listedDirectories(value: unknown): ListedDir[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (entry !== null && typeof entry === "object" && "name" in entry && "type" in entry) { + const record = entry as { name?: unknown; type?: unknown }; + if (typeof record.name === "string" && record.type === "dir") { + return [{ directory: record.name }]; + } + } + return []; + }); +} + +/** The repo body for stars; `null` when GitHub does not tell us. */ +async function officialStars(github: GitHub): Promise { + const response = await github.get( + `https://api.github.com/repos/${OFFICIAL_REPOSITORY}`, + "contents", + ); + if (isApiError(response)) { + return null; + } + return starsFromRepository(await response.json() as unknown); +} + +/** + * List the official catalogue. Both directories are read with two calls and + * the repository call gives stars once, so a warm cache costs three cache + * hits and each document costs its own folder call. + */ +export async function listOfficial( + github: GitHub, + log: Log, +): Promise { + const directories: Record = { + graph: [], + node: [], + }; + for (const kind of ["graph", "node"] as const) { + const listing = await github.get( + `https://api.github.com/repos/${OFFICIAL_REPOSITORY}/contents/registry/${kind}s`, + "contents", + ); + if (isApiError(listing)) { + return listing; + } + directories[kind] = listedDirectories(await listing.json() as unknown); + } + const stars = await officialStars(github); + if (stars === null) { + log("official: stars unavailable for the listing"); + } + + const graphs: CatalogEntry[] = []; + const nodes: CatalogEntry[] = []; + const buckets: Record = { graph: graphs, node: nodes }; + for (const kind of ["graph", "node"] as const) { + for (const listed of directories[kind]) { + if (graphs.length + nodes.length >= OFFICIAL_DOC_CAP) { + log("official: document cap reached"); + break; + } + const source = { + owner: OFFICIAL_OWNER, + repo: OFFICIAL_REPO, + directory: documentDirectory(OFFICIAL_OWNER, OFFICIAL_REPO, kind, listed.directory), + kind, + slug: listed.directory, + stars, + }; + const loaded = await loadDocument(github, source, log); + if (loaded.kind === "doc") { + buckets[kind].push(loaded.entry); + } + } + } + return { graphs: graphs.map((entry) => entry.doc), nodes: nodes.map((entry) => entry.doc) }; +} + +/** + * Load one official document by slug, trying graphs before nodes so an + * endpoint that does not say the kind still answers. `document` is the + * validated JSON itself, for the pages' renderer. + */ +export async function loadOfficialDocument( + github: GitHub, + slug: string, + log: Log, +): Promise< + | { kind: "doc"; entry: CatalogEntry } + | { kind: "absent" } + | ApiError +> { + const owner = OFFICIAL_OWNER; + const repo = OFFICIAL_REPO; + for (const kind of ["graph", "node"] as const) { + const source = { + owner, + repo, + directory: documentDirectory(owner, repo, kind, slug), + kind, + slug, + stars: null, + }; + const loaded = await loadDocument(github, source, log); + if (loaded.kind !== "absent") { + if (loaded.kind === "doc" && loaded.entry.doc.stars === null) { + loaded.entry.doc.stars = await officialStars(github); + } + return loaded; + } + } + return { kind: "absent" }; +} + +/** JSON value re-export so callers do not reach past this module. */ +export type { JsonValue }; diff --git a/worker/src/search.ts b/worker/src/search.ts new file mode 100644 index 0000000..6109dee --- /dev/null +++ b/worker/src/search.ts @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Community search for graphs.univerlab.org. + * + * One GitHub Search call over `topic:canopy-graph` plus the user's free + * text, then one git-trees call per repository to find document folders. + * GitHub's own ordering (stargazers or updated) is preserved exactly: the + * catalogue never re-sorts. The document cap keeps a request bounded, and a + * bounded-away page is reported with `truncated: true`. + */ +import type { DocumentKind } from "../../src/schema.js"; +import type { ApiError, GitHub, Log } from "./github.js"; +import { isApiError } from "./github.js"; +import { isNumber, isRecord, isString, stringField } from "./json.js"; +import type { DocumentSource } from "./documents.js"; +import { + type CatalogEntry, + type Doc, + type TreeFile, + documentDirectory, + pathsFromTree, + sourcesFromTree, +} from "./documents.js"; +import { loadDocument } from "./documents.js"; + +/** Repositories per page, matching the spec's 20-per-page contract. */ +export const SEARCH_PAGE_SIZE = 20; + +/** Upper bound on documents one search will render. */ +export const SEARCH_DOC_CAP = 20; + +export type SearchSort = "top" | "new"; + +export interface SearchInput { + q: string; + sort: SearchSort; + page: number; +} + +export interface SearchOutcome { + items: Doc[]; + q: string; + sort: SearchSort; + page: number; + total: number; + truncated: boolean; +} + +/** One repository as the Search API describes it. */ +interface RepoHit { + owner: string; + repo: string; + defaultBranch: string; + stars: number | null; +} + +function hitOf(value: unknown): RepoHit | null { + if (!isRecord(value)) { + return null; + } + const owner = isRecord(value["owner"]) ? stringField(value["owner"], "login") : ""; + const repo = stringField(value, "name"); + if (owner === "" || repo === "") { + return null; + } + const stars = isNumber(value["stargazers_count"]) ? value["stargazers_count"] : null; + return { + owner, + repo, + defaultBranch: stringField(value, "default_branch").trim() || "HEAD", + stars, + }; +} + +export function searchUrl(options: SearchInput): string { + const text = options.q.trim(); + const q = `topic:canopy-graph${text === "" ? "" : ` ${text}`}`; + const params = new URLSearchParams({ + q, + sort: options.sort === "new" ? "updated" : "stars", + order: "desc", + per_page: String(SEARCH_PAGE_SIZE), + page: String(options.page), + }); + return `https://api.github.com/search/repositories?${params.toString()}`; +} + +/** The dirs of `graphs/`/`nodes/`, the fallback when a tree is truncated. */ +async function directoriesFallback( + github: GitHub, + owner: string, + repo: string, + branch: string, + log: Log, +): Promise { + const sources: DocumentSource[] = []; + const prefix = branch === "" ? "" : `?ref=${encodeURIComponent(branch)}`; + for (const kind of ["graph", "node"] as const) { + const listing = await github.get( + `https://api.github.com/repos/${owner}/${repo}/contents/${kind}s${prefix}`, + "contents", + ); + if (isApiError(listing)) { + if (listing.kind !== "not-found") { + log(`search: ${owner}/${repo} listing ${kind}s failed: ${listing.kind}`); + } + continue; + } + const body = await listing.json() as unknown; + if (!Array.isArray(body)) { + continue; + } + for (const entry of body) { + if (isRecord(entry) && entry["type"] === "dir" && isString(entry["name"])) { + sources.push({ + owner, + repo, + directory: documentDirectory(owner, repo, kind, entry["name"]), + kind: kind as DocumentKind, + slug: entry["name"], + stars: null, + }); + } + } + } + return sources; +} + +/** + * Community search. Bounded by design: one search call, at most + * `SEARCH_PAGE_SIZE` tree calls (one fewer when the tree is truncated), + * at most `SEARCH_DOC_CAP` documents. + */ +export async function searchCommunity( + github: GitHub, + options: SearchInput, + log: Log, +): Promise { + const response = await github.get(searchUrl(options), "search"); + if (isApiError(response)) { + return response; + } + const body = await response.json() as unknown; + if (!isRecord(body) || !Array.isArray(body["items"])) { + return { + kind: "upstream", + message: "GitHub search answered an unexpected shape", + status: 502, + }; + } + const total = isNumber(body["total_count"]) ? body["total_count"] : 0; + + const entries: CatalogEntry[] = []; + let truncated = false; + for (const item of body["items"].slice(0, SEARCH_PAGE_SIZE)) { + const hit = hitOf(item); + if (hit === null) { + continue; + } + const treeResponse = await github.get( + `https://api.github.com/repos/${hit.owner}/${hit.repo}/git/trees/${encodeURIComponent(hit.defaultBranch)}?recursive=1`, + "contents", + ); + if (isApiError(treeResponse)) { + if (treeResponse.kind === "rate-limit") { + return treeResponse; + } + log(`search: ${hit.owner}/${hit.repo} skipped: ${treeResponse.kind}`); + continue; + } + const treeBody = await treeResponse.json() as unknown; + let files: TreeFile[] = pathsFromTree(treeBody); + let sources = sourcesFromTree(hit.owner, hit.repo, files); + const truncatedTree = isRecord(treeBody) && treeBody["truncated"] === true; + if (truncatedTree && sources.length === 0) { + sources = await directoriesFallback(github, hit.owner, hit.repo, hit.defaultBranch, log); + } + if (sources.length === 0) { + log(`search: ${hit.owner}/${hit.repo} tree has no document folders`); + continue; + } + if (sources.length > SEARCH_DOC_CAP - entries.length) { + truncated = true; + sources = sources.slice(0, Math.max(0, SEARCH_DOC_CAP - entries.length)); + } + for (const source of sources) { + source.stars = hit.stars; + const loaded = await loadDocument(github, source, log); + if (loaded.kind === "doc") { + entries.push(loaded.entry); + } + if (entries.length >= SEARCH_DOC_CAP) { + break; + } + } + if (entries.length >= SEARCH_DOC_CAP) { + truncated = true; + break; + } + } + + return { + items: entries.map((entry) => entry.doc), + q: options.q, + sort: options.sort, + page: options.page, + total, + truncated, + }; +} diff --git a/worker/src/validate.ts b/worker/src/validate.ts new file mode 100644 index 0000000..12a02ad --- /dev/null +++ b/worker/src/validate.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Document validation for the worker, wired to the one contract in `schema/`. + * + * The root `src/validate.ts` also reads the filesystem, which does not exist + * in a Worker, so this module keeps only the schema check itself — the same + * `@cfworker/json-schema` validators over the same imported `schema/*.json`, + * never a copied version of the rules. + */ +import { type Schema, Validator } from "@cfworker/json-schema"; +import { graphSchema, nodeSchema, type DocumentKind } from "../../src/schema.js"; +import { isRecord, type ParsedJson, parseJson } from "./json.js"; + +interface SchemaResult { + valid?: unknown; +} + +const validators: Record = { + graph: new Validator(graphSchema as Schema), + node: new Validator(nodeSchema as Schema), +}; + +/** True when the value is a document of `kind` that matches `schema/`. */ +export function isValidDocument( + kind: DocumentKind, + document: unknown, +): boolean { + return (validators[kind].validate(document) as SchemaResult).valid !== false; +} + +/** + * Parse and validate a document's JSON text against `schema/`. + * `ok: false` means the catalogue leaves the document out. + */ +export function parseDocument(kind: DocumentKind, text: string): ParsedJson & { document?: unknown } { + const parsed = parseJson(text); + if (!parsed.ok || !isRecord(parsed.value) || !isValidDocument(kind, parsed.value)) { + return { ok: false }; + } + return { ok: true, value: parsed.value, document: parsed.value }; +} diff --git a/worker/test/analytics.test.ts b/worker/test/analytics.test.ts new file mode 100644 index 0000000..b74b473 --- /dev/null +++ b/worker/test/analytics.test.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { + ANALYTICS_CACHE_URL, + attachDownloads, + createAnalytics, + downloadKey, + type Downloads, +} from "../src/analytics.js"; +import type { Doc } from "../src/documents.js"; +import { FakeCache, fakeFetch } from "./fakes.js"; + +const SQL_URL = "analytics_engine/sql"; + +function sampleDoc(slug: string, kind: "graph" | "node", repo: string): Doc { + return { + kind, + slug, + name: slug, + description: "", + readme: "", + nodeCount: kind === "node" ? 1 : 3, + repo, + stars: null, + }; +} + +describe("the analytics collector", () => { + it("returns null and calls nothing when unconfigured", async () => { + const fake = fakeFetch([]); + const analytics = createAnalytics({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const volumes = await analytics.volume(); + expect(volumes).toBeNull(); + expect(fake.calls.length).toBe(0); + }); + + it("queries the Analytics Engine SQL API and maps index counts", async () => { + const fake = fakeFetch([ + { + url: SQL_URL, + status: 200, + body: { + data: [ + { index: "UniverLab/graphs/graph/hello-graph", count: 12 }, + { index: "acme/things/node/cool-node", count: 4 }, + ], + }, + }, + ]); + const analytics = createAnalytics({ + fetchImpl: fake.fetchImpl, + cache: new FakeCache(), + accountId: "acct", + token: "tok", + }); + const volumes = (await analytics.volume()) as Downloads; + expect(volumes.get("UniverLab/graphs/graph/hello-graph")).toBe(12); + expect(volumes.get("acme/things/node/cool-node")).toBe(4); + expect(fake.calls.length).toBe(1); + expect(fake.calls[0]).toContain("accounts/acct/analytics_engine/sql"); + // Pin the real Analytics Engine columns: the index lives in index1, so + // the SQL must select and group by it, over the 90-day window. + expect(fake.bodies[0]).toContain("SELECT index1 AS index"); + expect(fake.bodies[0]).toContain("SUM(_sample_interval) AS count"); + expect(fake.bodies[0]).toContain(`INTERVAL '90' DAY`); + expect(fake.bodies[0]).toContain("GROUP BY index1"); + }); + + it("serves the second call from the cache without a second SQL call", async () => { + const fake = fakeFetch([ + { url: SQL_URL, status: 200, body: { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 12 }] } }, + ]); + const cache = new FakeCache(); + const analytics = createAnalytics({ + fetchImpl: fake.fetchImpl, + cache, + accountId: "acct", + token: "tok", + }); + await analytics.volume(); + const again = await analytics.volume(); + expect(fake.calls.length).toBe(1); + expect(cache.has(ANALYTICS_CACHE_URL)).toBe(true); + expect((again as Downloads).get("UniverLab/graphs/graph/hello-graph")).toBe(12); + }); + + it("returns null when the SQL call fails, never a throw", async () => { + const analytics = createAnalytics({ + fetchImpl: (async () => { + throw new Error("sql down"); + }) as unknown as typeof fetch, + cache: new FakeCache(), + accountId: "acct", + token: "tok", + }); + expect(await analytics.volume()).toBeNull(); + }); + + it("attaches 90-day counts where they match and nothing where they do not", () => { + const docs = [ + sampleDoc("hello-graph", "graph", "UniverLab/graphs"), + sampleDoc("quiet-graph", "graph", "UniverLab/graphs"), + ]; + const volumes = new Map([[downloadKey(docs[0]), 12]]); + const attached = attachDownloads(docs, volumes); + expect(attached[0].downloads).toBe(12); + expect("downloads" in attached[1]).toBe(false); + expect("downloads" in docs[0]).toBe(false); + }); + + it("leaves downloads absent altogether when analytics is unconfigured", () => { + const attached = attachDownloads([sampleDoc("hello-graph", "graph", "UniverLab/graphs")], null); + expect(attached[0].downloads).toBeUndefined(); + }); +}); diff --git a/worker/test/api.test.ts b/worker/test/api.test.ts new file mode 100644 index 0000000..10c6e57 --- /dev/null +++ b/worker/test/api.test.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { handleApi } from "../src/api.js"; +import { NOT_API } from "../src/api.js"; +import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; +import { INVALID_GRAPH_TEXT, VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; + +const SEARCH = "search/repositories"; +const REPO_DIR = "repos/UniverLab/graphs"; + +/** Everything one official graph plus one node need to answer an API call. */ +function stubs(): Stub[] { + return [ + { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: "hello-graph", type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: dirBody([{ name: "hello-node", type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", VALID_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n") }, + { url: `${REPO_DIR}/contents/registry/nodes/hello-node/node.json`, status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, + { url: `${REPO_DIR}/contents/registry/nodes/hello-node/README.md`, status: 404, body: {} }, + { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + ]; +} + +interface Options { + stubs?: Stub[]; + env?: Record; +} + +async function call(path: string, options: Options = {}): Promise { + const fake = fakeFetch(options.stubs ?? stubs()); + const cache = new FakeCache(); + const req = new Request(`https://graphs.univerlab.org${path}`); + const answer = await handleApi(req, options.env ?? {}, { + fetchImpl: fake.fetchImpl, + cache, + }); + expect(answer).not.toBe(true); + return answer as unknown as Response; +} + +describe("the JSON API", () => { + it("answers /api/official with graphs and nodes", async () => { + const response = await call("/api/official"); + expect(response.status).toBe(200); + const body = (await response.json()) as { graphs: unknown[]; nodes: unknown[] }; + expect(body.graphs.length).toBe(1); + expect(body.nodes.length).toBe(1); + }); + + it("answers /api/search with items and echoed filters", async () => { + const listing = [...stubs(), { + url: SEARCH, + status: 200, + body: { + total_count: 5, + items: [{ name: "things", owner: { login: "acme" }, default_branch: "main", stargazers_count: 2 }], + }, + }, { + url: "repos/acme/things/git/trees/main", + status: 200, + body: { tree: [{ path: "graphs/flow/graph.json", type: "blob" }] }, + }, { + url: "repos/acme/things/contents/graphs/flow/graph.json", + status: 200, + body: fileBody("graphs/flow/graph.json", VALID_GRAPH_TEXT), + }, { + url: "repos/acme/things/contents/graphs/flow/README.md", + status: 404, + body: {}, + }]; + const response = await call("/api/search?q=flow&sort=new&page=2", { stubs: listing }); + const body = (await response.json()) as { items: unknown[]; q: string; sort: string; page: number; total: number }; + expect(body.items.length).toBe(1); + expect(body.q).toBe("flow"); + expect(body.sort).toBe("new"); + expect(body.page).toBe(2); + expect(body.total).toBe(5); + }); + + it("rejects a bad sort and a bad page with typed 400s", async () => { + const badSort = await call("/api/search?sort=hottest"); + expect(badSort.status).toBe(400); + const badPage = await call("/api/search?page=zero"); + expect(badPage.status).toBe(400); + const body = (await badPage.json()) as { kind: string }; + expect(body.kind).toBe("invalid-query"); + }); + + it("answers /api/g/ with the validated document attached", async () => { + const response = await call("/api/g/UniverLab/graphs/hello-graph"); + expect(response.status).toBe(200); + const body = (await response.json()) as { + doc: { kind: string; slug: string; repo: string }; + document: { format_version: number; nodes: unknown[] }; + }; + expect(body.doc.kind).toBe("graph"); + expect(body.doc.slug).toBe("hello-graph"); + expect(body.document.format_version).toBe(6); + expect(Array.isArray(body.document.nodes)).toBe(true); + }); + + it("answers /api/n/ for a node document", async () => { + const response = await call("/api/n/acme/things/hello-node", { + stubs: [ + ...stubs(), + { url: "repos/acme/things/contents/nodes/hello-node/node.json", status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, + { url: "repos/acme/things/contents/nodes/hello-node/README.md", status: 404, body: {} }, + ], + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { doc: { kind: string; repo: string } }; + expect(body.doc.kind).toBe("node"); + expect(body.doc.repo).toBe("acme/things"); + }); + + it("answers a missing document with a typed 404, never a 500", async () => { + const fake = fakeFetch([ + { url: "repos/UniverLab/graphs/contents/registry/graphs/missing/graph.json", status: 404, body: {} }, + { url: "repos/UniverLab/graphs/contents/registry/nodes/missing/node.json", status: 404, body: {} }, + ]); + const cache = new FakeCache(); + const answer = await handleApi( + new Request("https://graphs.univerlab.org/api/g/UniverLab/graphs/missing"), + {}, + { fetchImpl: fake.fetchImpl, cache }, + ); + const response = answer as unknown as Response; + expect(response.status).toBe(404); + const body = (await response.json()) as { kind: string; message: string }; + expect(body.kind).toBe("invalid-document"); + expect(body.message).toContain("missing"); + }); + + it("surfaces a rate limit as a typed 429 for agents to show", async () => { + const response = await call("/api/official", { + stubs: [ + { + url: `${REPO_DIR}/contents/registry/graphs`, + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "2000000000" }, + }, + ], + }); + expect(response.status).toBe(429); + const body = (await response.json()) as { kind: string; retryAfter?: number }; + expect(body.kind).toBe("rate-limit"); + expect(body.retryAfter).toBeGreaterThan(0); + }); + + it("is CORS-open for GET and answers OPTIONS with 204", async () => { + const response = await call("/api/official"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + const options = await call("/api/official", { stubs: [] }); + void options; + const preflight = await handleApi( + new Request("https://graphs.univerlab.org/api/official", { method: "OPTIONS" }), + {}, + { cache: new FakeCache(), fetchImpl: fakeFetch([]).fetchImpl }, + ); + expect((preflight as unknown as Response).status).toBe(204); + }); + + it("answers unknown API routes with a typed 400", async () => { + const response = await call("/api/what"); + expect(response.status).toBe(400); + const body = (await response.json()) as { kind: string }; + expect(body.kind).toBe("invalid-path"); + }); + + it("keeps out of /api paths that are not its own routes", async () => { + const answer = await handleApi( + new Request("https://graphs.univerlab.org/i/graph/hello-graph"), + {}, + { cache: new FakeCache(), fetchImpl: fakeFetch([]).fetchImpl }, + ); + expect(answer).toBe(true); + }); + + it("does not list official stars as zero when the repo call fails", async () => { + const withoutStars = stubs().filter((stub) => stub.url !== "repos/UniverLab/graphs"); + withoutStars.push({ url: "repos/UniverLab/graphs", status: 404, body: {}, }); + const response = await call("/api/official", { stubs: withoutStars }); + const body = (await response.json()) as { graphs: Array<{ stars: unknown }> }; + expect(response.status).toBe(200); + expect(body.graphs[0].stars).toBeNull(); + }); +}); diff --git a/worker/test/cache.test.ts b/worker/test/cache.test.ts new file mode 100644 index 0000000..d606a37 --- /dev/null +++ b/worker/test/cache.test.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { createGitHub, TTL } from "../src/github.js"; +import { listOfficial } from "../src/official.js"; +import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; +import { VALID_GRAPH_TEXT } from "./documents-fixture.js"; + +const REPO_DIR = "repos/UniverLab/graphs"; + +const STUBS: Stub[] = [ + { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: "hello-graph", type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: [] }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", VALID_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n") }, + { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, +]; + +describe("the Cache API contract", () => { + it("serves a repeated request from the shared cache without new fetches", async () => { + const fake = fakeFetch(STUBS); + const cache = new FakeCache(); + const first = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + const listing = await listOfficial(first, () => undefined); + + expect(fake.calls.length).toBe(5); + const listed = listing as { graphs: Array<{ slug: string }> }; + expect(listed.graphs.length).toBe(1); + + // A second transport over the same cache: warm, no network needed. + const warmFake = fakeFetch([]); + const warm = createGitHub({ fetchImpl: warmFake.fetchImpl, cache }); + const again = await listOfficial(warm, () => undefined); + expect(warmFake.calls.length).toBe(0); + expect(again).toEqual(listing); + }); + + it("caches analytics results for an hour", async () => { + const analyticsBody = { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 3 }] }; + const fake = fakeFetch([ + { url: "analytics_engine/sql", status: 200, body: analyticsBody }, + ]); + const cache = new FakeCache(); + const one = await createGitHub({ + fetchImpl: fake.fetchImpl, + cache, + // only used to exercise TTL through a github-shaped key + }).get("https://canopy.ttl/analytics", "analytics"); + void one; + expect(TTL.analytics).toBe(3600); + expect(TTL.contents).toBe(1800); + expect(TTL.raw).toBe(1800); + expect(TTL.search).toBe(600); + }); +}); diff --git a/worker/test/documents-fixture.ts b/worker/test/documents-fixture.ts new file mode 100644 index 0000000..552eef7 --- /dev/null +++ b/worker/test/documents-fixture.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Document fixtures the tests serve as GitHub would: a valid graph, a valid + * node and a broken one, straight from `registry/` (the published examples + * are the contract) plus a hand-broken neighbour. + */ +import validGraphJson from "../../registry/graphs/hello-graph/graph.json" with { type: "json" }; +import validNodeJson from "../../registry/nodes/hello-node/node.json" with { type: "json" }; + +/** The published hello-graph document, as GitHub would answer it serialized. */ +export const VALID_GRAPH_TEXT = JSON.stringify(validGraphJson); + +/** The published hello-node document, likewise. */ +export const VALID_NODE_TEXT = JSON.stringify(validNodeJson); + +/** A structurally valid document with an unknown extra field: schema-invalid. */ +export const INVALID_GRAPH_TEXT = JSON.stringify({ + ...validGraphJson, + definitely_not_in_schema: 1, +}); + +/** A node missing its required description and config: schema-invalid. */ +export const BROKEN_NODE_TEXT = JSON.stringify({ + format_version: 1, + name: "broken-node", + kind: "agent", +}); diff --git a/worker/test/fakes.ts b/worker/test/fakes.ts new file mode 100644 index 0000000..01ad9fb --- /dev/null +++ b/worker/test/fakes.ts @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Fakes the worker tests inject instead of stubbing globals. + * + * FakeCache is a Map-backed CacheLike; fakeFetch answers from a route + * table and records every URL it was asked for. Tests wire them into + * createGitHub/createAnalytics/handleApi, never into the runtime. + */ +import { type CacheLike } from "../src/github.js"; + +interface Stored { + status: number; + headers: Record; + text: string; +} + +/** A Map-backed cache that counts what it served and stored. */ +export class FakeCache implements CacheLike { + private entries = new Map(); + misses = 0; + puts = 0; + + match(request: Request): Promise { + const found = this.entries.get(request.url); + if (found === undefined) { + this.misses += 1; + return Promise.resolve(undefined); + } + // The real Cache API hands out a fresh copy per match; so does the fake. + return Promise.resolve( + new Response(found.text, { + status: found.status, + headers: new Headers(found.headers), + }), + ); + } + + async put(request: Request, response: Response): Promise { + this.puts += 1; + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + const text = await response.clone().text(); + this.entries.set(request.url, { status: response.status, headers, text }); + } + + /** True when a URL is stored (a warm entry, even after it "expires"). */ + has(url: string): boolean { + return this.entries.has(url); + } +} + +/** One stubbed upstream answer. `body` is JSON-encoded, `text` stays raw. */ +export interface Stub { + /** Matched against the full request URL: exact string or part of it. */ + url: string; + status: number; + body?: unknown; + text?: string; + headers?: Record; +} + +export interface FakeFetch { + fetchImpl: typeof fetch; + calls: string[]; + /** The raw body sent with each call, in the same order as `calls`. */ + bodies: string[]; + /** Intercept paths explicitly NOT stubbed (a miss). */ + missing: string[]; +} + +/** Build a substitutable fetch over a route table, longest URL first. */ +export function fakeFetch(stubs: Stub[]): FakeFetch { + const ordered = [...stubs].sort((a, b) => b.url.length - a.url.length); + const calls: string[] = []; + const bodies: string[] = []; + const missing: string[] = []; + const fetchImpl = (async (input: URL | Request | string, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + bodies.push(typeof init?.body === "string" ? init.body : ""); + for (const stub of ordered) { + if (!url.includes(stub.url)) { + continue; + } + const headers = new Headers({ "content-type": "application/json", ...stub.headers }); + const text = stub.text ?? JSON.stringify(stub.body ?? {}); + return new Response(stub.body === undefined ? '' : text, { + status: stub.status, + headers, + }); + } + missing.push(url); + return new Response("{}", { status: 404, headers: new Headers() }); + }) as unknown as typeof fetch; + return { fetchImpl, calls, bodies, missing }; +} + +/** Standard base64 of a text, the contents-API encoding. */ +export function encoded(text: string): string { + return btoa(text); +} + +/** A contents-API file body carrying the text inline. */ +export function fileBody(path: string, text: string): Record { + return { + name: path.split("/").pop(), + path, + type: "file", + size: text.length, + content: encoded(text), + encoding: "base64", + }; +} + +/** A contents-API directory body holding these file names. */ +export function dirBody(entries: Array<{ name: string; type: string }>): unknown[] { + return entries.map((entry) => ({ + name: entry.name, + path: entry.name, + type: entry.type, + size: 0, + })); +} diff --git a/worker/test/github.test.ts b/worker/test/github.test.ts new file mode 100644 index 0000000..8909940 --- /dev/null +++ b/worker/test/github.test.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { createGitHub, TTL, USER_AGENT, type ApiError } from "../src/github.js"; +import { FakeCache, fakeFetch, type Stub } from "./fakes.js"; + +const url = "https://api.github.com/repos/UniverLab/graphs"; + +function run(stubs: Stub[], options: { token?: string } = {}): { + calls: string[]; + cache: FakeCache; +} { + const fake = fakeFetch(stubs); + const cache = new FakeCache(); + const github = createGitHub({ + fetchImpl: fake.fetchImpl, + cache, + token: options.token, + }); + void github; + return { calls: fake.calls, cache }; +} + +describe("the GitHub transport", () => { + it("sends a Bearer token, the catalogue User-Agent and a GitHub accept header", async () => { + const seen: Array = []; + const fetchImpl = (async (input: Request | string) => { + const request = input instanceof Request ? input : new Request(String(input)); + seen.push(request.headers); + return new Response(JSON.stringify({}), { status: 200 }); + }) as unknown as typeof fetch; + const github = createGitHub({ + fetchImpl, + cache: new FakeCache(), + token: "tok-123", + }); + const response = await github.get(url, "contents"); + expect(!(response as ApiError).kind).toBe(true); + expect(seen[0].get("authorization")).toBe("Bearer tok-123"); + expect(seen[0].get("user-agent")).toBe(USER_AGENT); + expect(seen[0].get("accept")).toContain("vnd.github"); + }); + + it("omits the Bearer header when no token exists", async () => { + const { calls } = run([{ url, status: 200, body: {} }]); + void calls; + const seen: Array = []; + const fetchImpl = (async (input: Request | string) => { + const request = input instanceof Request ? input : new Request(String(input)); + seen.push(request.headers); + return new Response(JSON.stringify({}), { status: 200 }); + }) as unknown as typeof fetch; + const github = createGitHub({ fetchImpl, cache: new FakeCache() }); + await github.get(url, "contents"); + expect(seen[0].get("authorization")).toBeNull(); + }); + + it("serves the second identical call from the cache", async () => { + const fake = fakeFetch([{ url, status: 200, body: { ok: 1 } }]); + const cache = new FakeCache(); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + const first = await github.get(url, "contents"); + const second = await github.get(url, "contents"); + expect(String((first as Response).status)).toBe("200"); + expect(fake.calls.length).toBe(1); + expect(cache.puts).toBe(1); + // a cache hit is a fresh, usable copy, not the same consumed object + const cached = second as Response; + expect(String(cached.status)).toBe("200"); + expect(JSON.stringify(await cached.json())).toBe(JSON.stringify({ ok: 1 })); + // the stored response still has its body: the clone keeps it usable + const again = await github.get(url, "contents"); + expect(JSON.stringify(await (again as Response).json())).toBe(JSON.stringify({ ok: 1 })); + }); + + it("stores search and contents with their own time-to-live", async () => { + const fake = fakeFetch([ + { url, status: 200, body: {} }, + { url: "https://api.github.com/search/repositories", status: 200, body: {} }, + ]); + const cache = new FakeCache(); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + await github.get(url, "contents"); + await github.get("https://api.github.com/search/repositories?x=1", "search"); + const contentsTtl = (await cache.match(new Request(url)))?.headers.get("cache-control"); + const searchTtl = + (await cache.match(new Request("https://api.github.com/search/repositories?x=1"))) + ?.headers.get("cache-control") ?? ""; + expect(contentsTtl).toContain(`max-age=${TTL.contents}`); + expect(searchTtl).toContain(`max-age=${TTL.search}`); + }); + + it("surfaces a rate limit as a typed 429 with a retry-after", async () => { + const fake = fakeFetch([ + { + url, + status: 403, + body: { message: "API rate limit exceeded" }, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "2000000000" }, + }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const error = await github.get(url, "contents"); + expect((error as ApiError).kind).toBe("rate-limit"); + expect((error as ApiError).status).toBe(429); + expect((error as ApiError).retryAfter).toBeGreaterThan(0); + }); + + it("treats a 429 header answer as a rate limit too", async () => { + const fake = fakeFetch([ + { + url, + status: 429, + body: "", + headers: { "x-ratelimit-remaining": "0" }, + }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const error = await github.get(url, "contents"); + expect((error as ApiError).kind).toBe("rate-limit"); + }); + + it("maps 404 to a not-found error and other failures to an upstream error", async () => { + const missing = "https://api.github.com/repos/a/b/contents/nope"; + const fake = fakeFetch([ + { url: missing, status: 404, body: {} }, + { url: "https://api.github.com/repos/a/b/contents/broken", status: 502, body: "" }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const notFound = await github.get(missing, "contents"); + const upstream = await github.get( + "https://api.github.com/repos/a/b/contents/broken", + "contents", + ); + expect((notFound as ApiError).kind).toBe("not-found"); + expect((notFound as ApiError).status).toBe(404); + expect((upstream as ApiError).kind).toBe("upstream"); + expect((upstream as ApiError).status).toBe(502); + }); + + it("turns a network failure into an upstream error, never a throw", async () => { + const fetchImpl = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + const github = createGitHub({ fetchImpl, cache: new FakeCache() }); + const error = await github.get(url, "contents"); + expect((error as ApiError).kind).toBe("upstream"); + expect((error as ApiError).status).toBe(502); + }); + + it("keeps a warm entry serving when the upstream starts rate limiting", async () => { + const fake = fakeFetch([{ url, status: 200, body: { ok: 1 } }]); + const cache = new FakeCache(); + const warm = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + await warm.get(url, "contents"); + expect(fake.calls.length).toBe(1); + + const limited = fakeFetch([ + { url, status: 403, headers: { "x-ratelimit-remaining": "0" } }, + ]); + const cold = createGitHub({ fetchImpl: limited.fetchImpl, cache }); + const stillWarm = await cold.get(url, "contents"); + expect(limited.calls.length).toBe(0); + expect(String((stillWarm as Response).status)).toBe("200"); + }); + + it("does not store a failure response", async () => { + const fake = fakeFetch([{ url, status: 404, body: {} }]); + const cache = new FakeCache(); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + await github.get(url, "contents"); + expect(cache.has(url)).toBe(false); + expect(cache.puts).toBe(0); + }); +}); diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts index ea2c23a..cccf52f 100644 --- a/worker/test/index.test.ts +++ b/worker/test/index.test.ts @@ -31,7 +31,7 @@ function request(path: string, options: Options = {}): Request { } function call(path: string, options: Options = {}): Response { - return worker.fetch(request(path, options), options.env ?? {}); + return worker.fetch(request(path, options), options.env ?? {}) as Response; } /** An env whose Analytics Engine binding records what the Worker sends it. */ diff --git a/worker/test/official.test.ts b/worker/test/official.test.ts new file mode 100644 index 0000000..1d5bd12 --- /dev/null +++ b/worker/test/official.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import type { OfficialListing } from "../src/official.js"; +import { listOfficial } from "../src/official.js"; +import { createGitHub } from "../src/github.js"; +import { FakeCache, dirBody, fakeFetch, fileBody } from "./fakes.js"; +import { INVALID_GRAPH_TEXT, VALID_GRAPH_TEXT } from "./documents-fixture.js"; + +const REPO_DIR = "repos/UniverLab/graphs"; + +/** The GitHub-shaped stubs behind one and two official documents. */ +const STUBS = (brokenToo: boolean) => [ + { + url: `${REPO_DIR}/contents/registry/graphs`, + status: 200, + body: dirBody( + brokenToo + ? [{ name: "broken-graph", type: "dir" }, { name: "hello-graph", type: "dir" }] + : [{ name: "hello-graph", type: "dir" }], + ), + }, + { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: [] }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", VALID_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/broken-graph/graph.json`, status: 200, body: fileBody("graphs/broken-graph/graph.json", INVALID_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n\nA tiny graph.\n") }, + { url: `${REPO_DIR}/contents/registry/graphs/broken-graph/README.md`, status: 404, body: {} }, + { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, +]; + +describe("the official listing", () => { + it("lists a valid document with every field the pages render", async () => { + const fake = fakeFetch(STUBS(false)); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const listing = (await listOfficial(github, () => undefined)) as OfficialListing; + + expect("kind" in listing).toBe(false); + expect(listing.graphs.length).toBe(1); + expect(listing.nodes.length).toBe(0); + const doc = listing.graphs[0]; + expect(doc.kind).toBe("graph"); + expect(doc.slug).toBe("hello-graph"); + expect(doc.name).toBe("hello-graph"); + expect(doc.readme).toContain("A tiny graph"); + expect(doc.nodeCount).toBe(4); + expect(doc.repo).toBe("UniverLab/graphs"); + expect(doc.stars).toBe(7); + expect("downloads" in doc).toBe(false); + }); + + it("drops an invalid document and still lists the valid ones", async () => { + const fake = fakeFetch(STUBS(true)); + const cache = new FakeCache(); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache }); + const notes: string[] = []; + const listing = (await listOfficial(github, (message) => notes.push(message))) as OfficialListing; + + expect(listing.graphs.length).toBe(1); + expect(listing.graphs[0].slug).toBe("hello-graph"); + expect(notes.some((message) => message.includes("broken-graph"))).toBe(true); + }); + + it("lists a document whose README is missing, with an empty readme", async () => { + const withoutReadme = [ + STUBS(false)[0], + STUBS(false)[1], + STUBS(false)[2], + STUBS(false)[6], + ]; + const fake = fakeFetch(withoutReadme); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const listing = (await listOfficial(github, () => undefined)) as OfficialListing; + expect(listing.graphs.length).toBe(1); + expect(listing.graphs[0].readme).toBe(""); + }); + + it("surfaces a rate limit from the directory listing as a typed error", async () => { + const fake = fakeFetch([ + { + url: `${REPO_DIR}/contents/registry/graphs`, + status: 403, + headers: { "x-ratelimit-remaining": "0" }, + }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const error = await listOfficial(github, () => undefined); + expect("kind" in error ? error.kind : "").toBe("rate-limit"); + }); +}); diff --git a/worker/test/search.test.ts b/worker/test/search.test.ts new file mode 100644 index 0000000..de80307 --- /dev/null +++ b/worker/test/search.test.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { searchCommunity, type SearchOutcome } from "../src/search.js"; +import { createGitHub } from "../src/github.js"; +import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; +import { BROKEN_NODE_TEXT, INVALID_GRAPH_TEXT, VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; + +const SEARCH = "search/repositories"; + +interface Repo { + name: string; + graphText: string; + stars: number; +} + +/** Stubs for a search of these repositories, each with one graph folder. */ +function repoStubs(repos: Repo[]): Stub[] { + const stubs: Stub[] = [ + { + url: SEARCH, + status: 200, + body: { + total_count: repos.length + 3, + items: repos.map((repo) => ({ + name: repo.name, + owner: { login: "acme" }, + default_branch: "main", + stargazers_count: repo.stars, + })), + }, + }, + ]; + for (const repo of repos) { + stubs.push({ + url: `repos/acme/${repo.name}/git/trees/main`, + status: 200, + body: { + tree: [ + { path: "README.md", type: "blob" }, + { path: `graphs/flow/graph.json`, type: "blob" }, + { path: `graphs/flow/README.md`, type: "blob" }, + ], + }, + }); + stubs.push({ + url: `repos/acme/${repo.name}/contents/graphs/flow/graph.json`, + status: 200, + body: fileBody("graphs/flow/graph.json", repo.graphText), + }); + stubs.push({ + url: `repos/acme/${repo.name}/contents/graphs/flow/README.md`, + status: 200, + body: fileBody("graphs/flow/README.md", `# ${repo.name}\n`), + }); + } + return stubs; +} + +async function searched(stubs: Stub[], input: { q: string; sort: string; page: number }): Promise { + const fake = fakeFetch(stubs); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const notes: string[] = []; + const outcome = (await searchCommunity( + github, + { q: input.q, sort: input.sort === "new" ? "new" : "top", page: input.page }, + (message) => notes.push(message), + )) as SearchOutcome; + void fake; + return outcome; +} + +describe("community search", () => { + it("adds the free text to the topic filter and echoes the query", async () => { + const outcome = await searched(repoStubs([{ name: "things", graphText: VALID_GRAPH_TEXT, stars: 5 }]), { + q: "scheduled agents", + sort: "top", + page: 1, + }); + expect(outcome.q).toBe("scheduled agents"); + expect(outcome.items.length).toBe(1); + expect(outcome.total).toBe(4); + }); + + it("maps sort=new to GitHub's updated ordering", async () => { + const fake = fakeFetch(repoStubs([])); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + await searchCommunity(github, { q: "", sort: "new", page: 2 }, () => undefined); + expect(fake.calls[0]).toContain("sort=updated"); + expect(fake.calls[0]).toContain("page=2"); + }); + + it("maps sort=top to GitHub's star ordering", async () => { + const fake = fakeFetch(repoStubs([{ name: "things", graphText: VALID_GRAPH_TEXT, stars: 5 }])); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined); + expect(fake.calls[0]).toContain("sort=stars"); + expect(fake.calls[0]).toContain("topic%3Acanopy-graph"); + }); + + it("preserves GitHub's ordering verbatim, stars or not", async () => { + const outcome = await searched( + repoStubs([ + { name: "b", graphText: VALID_GRAPH_TEXT, stars: 1 }, + { name: "a", graphText: VALID_GRAPH_TEXT, stars: 20 }, + ]), + { q: "", sort: "top", page: 1 }, + ); + expect(outcome.items[0].repo).toBe("acme/b"); + expect(outcome.items[1].repo).toBe("acme/a"); + expect(outcome.items[0].stars).toBe(1); + expect(outcome.items[1].stars).toBe(20); + }); + + it("finds graphs and nodes in one repo and drops invalid documents", async () => { + const fake = fakeFetch([ + { + url: SEARCH, + status: 200, + body: { + total_count: 1, + items: [{ name: "things", owner: { login: "acme" }, default_branch: "main", stargazers_count: 9 }], + }, + }, + { + url: "repos/acme/things/git/trees/main", + status: 200, + body: { + tree: [ + { path: "graphs/flow/graph.json", type: "blob" }, + { path: "graphs/broken/graph.json", type: "blob" }, + { path: "nodes/hello-node/node.json", type: "blob" }, + { path: "nodes/void/node.json", type: "blob" }, + ], + }, + }, + { url: "repos/acme/things/contents/graphs/flow/graph.json", status: 200, body: fileBody("graphs/flow/graph.json", INVALID_GRAPH_TEXT) }, + { url: "repos/acme/things/contents/graphs/broken/graph.json", status: 200, body: fileBody("graphs/broken/graph.json", BROKEN_NODE_TEXT) }, + { url: "repos/acme/things/contents/nodes/hello-node/node.json", status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, + { url: "repos/acme/things/contents/nodes/void/node.json", status: 404, body: {} }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const outcome = (await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined)) as SearchOutcome; + expect(outcome.items.length).toBe(1); + expect(outcome.items[0].kind).toBe("node"); + expect(outcome.items[0].slug).toBe("hello-node"); + expect(outcome.items[0].stars).toBe(9); + }); + + it("falls back to two directory listings when a tree is truncated", async () => { + const fake = fakeFetch([ + { + url: SEARCH, + status: 200, + body: { + total_count: 1, + items: [{ name: "things", owner: { login: "acme" }, default_branch: "main", stargazers_count: 3 }], + }, + }, + { + url: "repos/acme/things/git/trees/main", + status: 200, + body: { truncated: true, tree: [{ path: ".github/workflows/ci.yml", type: "blob" }] }, + }, + { url: "repos/acme/things/contents/graphs", status: 200, body: dirBody([{ name: "flow", type: "dir" }]) }, + { url: "repos/acme/things/contents/nodes", status: 200, body: [] }, + { url: "repos/acme/things/contents/graphs/flow/graph.json", status: 200, body: fileBody("graphs/flow/graph.json", VALID_GRAPH_TEXT) }, + { url: "repos/acme/things/contents/graphs/flow/README.md", status: 404, body: {} }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const outcome = (await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined)) as SearchOutcome; + expect(outcome.items.length).toBe(1); + expect(outcome.items[0].slug).toBe("flow"); + expect(outcome.items[0].readme).toBe(""); + }); + + it("surfaces a rate limit from the search call as a typed error", async () => { + const fake = fakeFetch([ + { url: SEARCH, status: 429, headers: { "x-ratelimit-remaining": "0" } }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const outcome = await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined); + expect("kind" in outcome ? outcome.kind : "").toBe("rate-limit"); + }); +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml index de163f4..2011c7c 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -8,6 +8,12 @@ name = "canopy-graphs" main = "src/index.ts" compatibility_date = "2026-10-26" +[vars] +# The Analytics Engine account the downloads API reads; the SQL API is the +# only consumer. The token arrives as a secret (CF_ANALYTICS_TOKEN), the +# token for GitHub arrives as GITHUB_TOKEN. +CF_ACCOUNT_ID = "d7b81bdd721f503968523dd903d967be" + # Inlined on purpose: the account has a single Worker account, and the deploy # workflow then needs exactly one secret (CLOUDFLARE_API_TOKEN). account_id = "d7b81bdd721f503968523dd903d967be" From 90f54a4a98c013b0b79a1b7fc244684d628666a9 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 18:23:44 -0500 Subject: [PATCH 007/180] feat(worker): discover official and community graphs through GitHub with edge caching (reviewer 2 fixes) --- worker/README.md | 4 +++ worker/src/api.ts | 59 ++++++++++++++++----------------- worker/src/documents.ts | 16 ++++++--- worker/src/json.ts | 19 +++++++++++ worker/src/official.ts | 52 ++++++++++++++++------------- worker/src/search.ts | 38 ++++++++++++++-------- worker/test/api.test.ts | 63 ++++++++++++++++++++++++++++++++++++ worker/test/fakes.ts | 5 +-- worker/test/official.test.ts | 26 +++++++++++++++ worker/test/search.test.ts | 34 +++++++++++++++++++ 10 files changed, 243 insertions(+), 73 deletions(-) diff --git a/worker/README.md b/worker/README.md index fbd7f93..39613d5 100644 --- a/worker/README.md +++ b/worker/README.md @@ -62,6 +62,10 @@ database, the Cache API is still the only state: | `GET /api/g///` | one graph document, validated JSON attached | | `GET /api/n///` | one node document, validated JSON attached | +The `g`/`n` in a single-document route is the kind asked for: a slug that only +exists under the other kind is a typed `404`, never a document of the wrong +kind. + Every answer carries `access-control-allow-origin: *` (GET only, OPTIONS → 204) and short cache-control (60 s for search, 300 s otherwise). Errors are typed JSON bodies — `{kind, message, status, retryAfter?}` with kinds diff --git a/worker/src/api.ts b/worker/src/api.ts index bda1721..7cf3beb 100644 --- a/worker/src/api.ts +++ b/worker/src/api.ts @@ -14,6 +14,7 @@ */ import { attachDownloads, createAnalytics } from "./analytics.js"; import { createGitHub, isApiError, type ApiError, type CacheLike, type GitHub, type Log } from "./github.js"; +import type { DocumentKind } from "../../src/schema.js"; import { SLUG_RE, OWNER_RE, REPO_RE, isOfficialRepo } from "./lib.js"; import { documentDirectory, loadDocument } from "./documents.js"; import { listOfficial, loadOfficialDocument } from "./official.js"; @@ -113,47 +114,42 @@ function partsInvalid(owner: string, repo: string, slug: string): string | null return null; } +/** + * One document, of the kind the route names: `GET /api/g` answers a graph or + * nothing, `GET /api/n` a node or nothing. `document` is the validated JSON + * itself, for the pages' renderer. + */ async function singleDocument( github: GitHub, + kind: DocumentKind, owner: string, repo: string, slug: string, log: Log, ): Promise { - if (isOfficialRepo(owner, repo)) { - const loaded = await loadOfficialDocument(github, slug, log); - if (loaded.kind === "doc") { - return jsonResponse(loaded.entry, 200, 300); - } - if (loaded.kind === "absent") { - return errorResponse({ - kind: "invalid-document", - message: `no official graph or node named ${slug}`, - status: 404, - }); - } - return errorResponse(loaded); + const loaded = isOfficialRepo(owner, repo) + ? await loadOfficialDocument(github, kind, slug, log) + : await loadDocument( + github, + { + owner, + repo, + directory: documentDirectory(owner, repo, kind, slug), + kind, + slug, + stars: null, + }, + log, + ); + if (loaded.kind === "doc") { + return jsonResponse(loaded.entry, 200, 300); } - for (const kind of ["graph", "node"] as const) { - const source = { - owner, - repo, - directory: documentDirectory(owner, repo, kind, slug), - kind, - slug, - stars: null, - }; - const loaded = await loadDocument(github, source, log); - if (loaded.kind === "doc") { - return jsonResponse(loaded.entry, 200, 300); - } - if (loaded.kind !== "absent") { - return errorResponse(loaded); - } + if (loaded.kind !== "absent") { + return errorResponse(loaded); } return errorResponse({ kind: "invalid-document", - message: `no graph or node named ${slug} in ${owner}/${repo}`, + message: `no ${kind} named ${slug} in ${owner}/${repo}`, status: 404, }); } @@ -251,7 +247,8 @@ export async function handleApi( if (invalid !== null) { return errorResponse(invalidPath(invalid)); } - return singleDocument(github, owner, repo, slug, log); + const kind: DocumentKind = route.verb === "g" ? "graph" : "node"; + return singleDocument(github, kind, owner, repo, slug, log); } default: return errorResponse(invalidPath("unknown API route")); diff --git a/worker/src/documents.ts b/worker/src/documents.ts index bef89d9..a4995d3 100644 --- a/worker/src/documents.ts +++ b/worker/src/documents.ts @@ -12,7 +12,7 @@ import type { DocumentKind, JsonValue } from "../../src/schema.js"; import type { ApiError, GitHub } from "./github.js"; import { isApiError } from "./github.js"; -import { decodeBase64, isArray, isNumber, isRecord, isString, parseJson, stringField } from "./json.js"; +import { decodeBase64, isArray, isNumber, isRecord, isString, parseJson, readJson, stringField } from "./json.js"; import { isValidDocument } from "./validate.js"; import { isOfficialRepo } from "./lib.js"; @@ -154,9 +154,17 @@ async function readText( log(`documents: ${path} absent at the contents API, trying raw`); return rawText(github, owner, repo, path, ref); } - const body = await file.json() as unknown; - if (isRecord(body) && body["content"] !== undefined && body["content"] !== null) { - const decoded = decodeBase64(body["content"]); + const body = await readJson(file); + if (!body.ok) { + log(`documents: ${path} answered something other than JSON, trying raw`); + return rawText(github, owner, repo, path, ref); + } + const content = isRecord(body.value) ? body.value["content"] : undefined; + const encoding = isRecord(body.value) ? stringField(body.value, "encoding") : ""; + // Files over 1 MB arrive with `content: ""` and `encoding: "none"`; an empty + // inline body is never the file, so it goes to the raw URL as well. + if (isString(content) && content !== "" && encoding !== "none") { + const decoded = decodeBase64(content); if (decoded !== null) { return { kind: "text", text: decoded }; } diff --git a/worker/src/json.ts b/worker/src/json.ts index 384dacd..a1fdcd4 100644 --- a/worker/src/json.ts +++ b/worker/src/json.ts @@ -25,6 +25,25 @@ export function parseJson(text: string): ParsedJson { } } +/** A response body that parsed as JSON; `ok: false` means it never will. */ +export type JsonRead = { ok: true; value: JsonValue } | { ok: false }; + +/** + * Read a response body as JSON without ever throwing. GitHub answers JSON on + * every success, but a proxy or a captive portal can hand back HTML with a + * 200: that is an upstream problem the caller shapes into a typed error, not + * an exception that reaches the user as a 500. + */ +export async function readJson(response: Response): Promise { + let text: string; + try { + text = await response.text(); + } catch { + return { ok: false }; + } + return parseJson(text); +} + /** True when the value is a JSON object (and not null, not an array). */ export function isRecord( value: unknown, diff --git a/worker/src/official.ts b/worker/src/official.ts index 958e3f1..74dd091 100644 --- a/worker/src/official.ts +++ b/worker/src/official.ts @@ -15,6 +15,7 @@ import { isApiError, } from "./github.js"; import { OFFICIAL_OWNER, OFFICIAL_REPOSITORY, OFFICIAL_REPO } from "./lib.js"; +import { readJson } from "./json.js"; import { type CatalogEntry, type Doc, @@ -61,7 +62,8 @@ async function officialStars(github: GitHub): Promise { if (isApiError(response)) { return null; } - return starsFromRepository(await response.json() as unknown); + const body = await readJson(response); + return body.ok ? starsFromRepository(body.value) : null; } /** @@ -85,7 +87,16 @@ export async function listOfficial( if (isApiError(listing)) { return listing; } - directories[kind] = listedDirectories(await listing.json() as unknown); + const body = await readJson(listing); + if (!body.ok) { + log(`official: registry/${kind}s listing did not parse as JSON`); + return { + kind: "upstream", + message: `GitHub contents for registry/${kind}s answered something other than JSON`, + status: 502, + }; + } + directories[kind] = listedDirectories(body.value); } const stars = await officialStars(github); if (stars === null) { @@ -119,12 +130,14 @@ export async function listOfficial( } /** - * Load one official document by slug, trying graphs before nodes so an - * endpoint that does not say the kind still answers. `document` is the + * Load one official document of an explicit kind. A slug that only exists + * under the other kind is simply absent: `GET /api/g` never answers with a + * node, and the miss costs one folder probe instead of two. `document` is the * validated JSON itself, for the pages' renderer. */ export async function loadOfficialDocument( github: GitHub, + kind: DocumentKind, slug: string, log: Log, ): Promise< @@ -132,26 +145,19 @@ export async function loadOfficialDocument( | { kind: "absent" } | ApiError > { - const owner = OFFICIAL_OWNER; - const repo = OFFICIAL_REPO; - for (const kind of ["graph", "node"] as const) { - const source = { - owner, - repo, - directory: documentDirectory(owner, repo, kind, slug), - kind, - slug, - stars: null, - }; - const loaded = await loadDocument(github, source, log); - if (loaded.kind !== "absent") { - if (loaded.kind === "doc" && loaded.entry.doc.stars === null) { - loaded.entry.doc.stars = await officialStars(github); - } - return loaded; - } + const source = { + owner: OFFICIAL_OWNER, + repo: OFFICIAL_REPO, + directory: documentDirectory(OFFICIAL_OWNER, OFFICIAL_REPO, kind, slug), + kind, + slug, + stars: null, + }; + const loaded = await loadDocument(github, source, log); + if (loaded.kind === "doc" && loaded.entry.doc.stars === null) { + loaded.entry.doc.stars = await officialStars(github); } - return { kind: "absent" }; + return loaded; } /** JSON value re-export so callers do not reach past this module. */ diff --git a/worker/src/search.ts b/worker/src/search.ts index 6109dee..c57f094 100644 --- a/worker/src/search.ts +++ b/worker/src/search.ts @@ -11,12 +11,11 @@ import type { DocumentKind } from "../../src/schema.js"; import type { ApiError, GitHub, Log } from "./github.js"; import { isApiError } from "./github.js"; -import { isNumber, isRecord, isString, stringField } from "./json.js"; +import { isArray, isNumber, isRecord, isString, readJson, stringField } from "./json.js"; import type { DocumentSource } from "./documents.js"; import { type CatalogEntry, type Doc, - type TreeFile, documentDirectory, pathsFromTree, sourcesFromTree, @@ -106,11 +105,11 @@ async function directoriesFallback( } continue; } - const body = await listing.json() as unknown; - if (!Array.isArray(body)) { + const body = await readJson(listing); + if (!body.ok || !Array.isArray(body.value)) { continue; } - for (const entry of body) { + for (const entry of body.value) { if (isRecord(entry) && entry["type"] === "dir" && isString(entry["name"])) { sources.push({ owner, @@ -140,19 +139,28 @@ export async function searchCommunity( if (isApiError(response)) { return response; } - const body = await response.json() as unknown; - if (!isRecord(body) || !Array.isArray(body["items"])) { + const body = await readJson(response); + if (!body.ok || !isRecord(body.value)) { return { kind: "upstream", message: "GitHub search answered an unexpected shape", status: 502, }; } - const total = isNumber(body["total_count"]) ? body["total_count"] : 0; + const payload = body.value; + const items = payload["items"]; + if (!isArray(items)) { + return { + kind: "upstream", + message: "GitHub search answered an unexpected shape", + status: 502, + }; + } + const total = isNumber(payload["total_count"]) ? payload["total_count"] : 0; const entries: CatalogEntry[] = []; let truncated = false; - for (const item of body["items"].slice(0, SEARCH_PAGE_SIZE)) { + for (const item of items.slice(0, SEARCH_PAGE_SIZE)) { const hit = hitOf(item); if (hit === null) { continue; @@ -168,10 +176,14 @@ export async function searchCommunity( log(`search: ${hit.owner}/${hit.repo} skipped: ${treeResponse.kind}`); continue; } - const treeBody = await treeResponse.json() as unknown; - let files: TreeFile[] = pathsFromTree(treeBody); - let sources = sourcesFromTree(hit.owner, hit.repo, files); - const truncatedTree = isRecord(treeBody) && treeBody["truncated"] === true; + const treeBody = await readJson(treeResponse); + if (!treeBody.ok) { + log(`search: ${hit.owner}/${hit.repo} tree did not parse as JSON, skipped`); + continue; + } + const tree = treeBody.value; + let sources = sourcesFromTree(hit.owner, hit.repo, pathsFromTree(tree)); + const truncatedTree = isRecord(tree) && tree["truncated"] === true; if (truncatedTree && sources.length === 0) { sources = await directoriesFallback(github, hit.owner, hit.repo, hit.defaultBranch, log); } diff --git a/worker/test/api.test.ts b/worker/test/api.test.ts index 10c6e57..6bf1404 100644 --- a/worker/test/api.test.ts +++ b/worker/test/api.test.ts @@ -113,6 +113,50 @@ describe("the JSON API", () => { expect(body.doc.repo).toBe("acme/things"); }); + it("answers the kind the route names, never the other one", async () => { + const fake = fakeFetch([ + { + url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, + status: 200, + body: fileBody("graphs/hello-graph/graph.json", VALID_GRAPH_TEXT), + }, + ]); + const answer = await handleApi( + new Request("https://graphs.univerlab.org/api/n/UniverLab/graphs/hello-graph"), + {}, + { fetchImpl: fake.fetchImpl, cache: new FakeCache() }, + ); + const response = answer as unknown as Response; + expect(response.status).toBe(404); + const body = (await response.json()) as { kind: string; message: string }; + expect(body.kind).toBe("invalid-document"); + expect(body.message).toContain("no node named"); + // A node request never reads the graph folder, so a miss stays one probe. + expect(fake.calls.some((url) => url.includes("registry/graphs/hello-graph"))).toBe(false); + }); + + it("turns an unreadable upstream body into a typed error, never a throw", async () => { + const html = { "content-type": "text/html" }; + const listing = await call("/api/official", { + stubs: [{ url: `${REPO_DIR}/contents/registry/graphs`, status: 200, text: "portal", headers: html }], + }); + expect(listing.status).toBe(502); + expect(((await listing.json()) as { kind: string }).kind).toBe("upstream"); + + const document = await call("/api/g/UniverLab/graphs/hello-graph", { + stubs: [ + { + url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, + status: 200, + text: "portal", + headers: html, + }, + ], + }); + expect(document.status).toBe(404); + expect(((await document.json()) as { kind: string }).kind).toBe("invalid-document"); + }); + it("answers a missing document with a typed 404, never a 500", async () => { const fake = fakeFetch([ { url: "repos/UniverLab/graphs/contents/registry/graphs/missing/graph.json", status: 404, body: {} }, @@ -176,6 +220,25 @@ describe("the JSON API", () => { expect(answer).toBe(true); }); + it("attaches install counts when analytics is configured and leaves them out otherwise", async () => { + const enriched = await call("/api/official", { + env: { CF_ACCOUNT_ID: "acct", CF_ANALYTICS_TOKEN: "tok" }, + stubs: [ + ...stubs(), + { + url: "analytics_engine/sql", + status: 200, + body: { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 5 }] }, + }, + ], + }); + const withCounts = (await enriched.json()) as { graphs: Array<{ downloads?: number }> }; + expect(withCounts.graphs[0].downloads).toBe(5); + + const plain = (await (await call("/api/official")).json()) as { graphs: Array<{ downloads?: number }> }; + expect(plain.graphs[0].downloads).toBeUndefined(); + }); + it("does not list official stars as zero when the repo call fails", async () => { const withoutStars = stubs().filter((stub) => stub.url !== "repos/UniverLab/graphs"); withoutStars.push({ url: "repos/UniverLab/graphs", status: 404, body: {}, }); diff --git a/worker/test/fakes.ts b/worker/test/fakes.ts index 01ad9fb..81dad04 100644 --- a/worker/test/fakes.ts +++ b/worker/test/fakes.ts @@ -85,8 +85,9 @@ export function fakeFetch(stubs: Stub[]): FakeFetch { continue; } const headers = new Headers({ "content-type": "application/json", ...stub.headers }); - const text = stub.text ?? JSON.stringify(stub.body ?? {}); - return new Response(stub.body === undefined ? '' : text, { + // `text` is a raw body (HTML, empty); `body` is JSON-encoded; absent both is "". + const payload = stub.text ?? (stub.body === undefined ? "" : JSON.stringify(stub.body)); + return new Response(payload, { status: stub.status, headers, }); diff --git a/worker/test/official.test.ts b/worker/test/official.test.ts index 1d5bd12..a35b5c2 100644 --- a/worker/test/official.test.ts +++ b/worker/test/official.test.ts @@ -73,6 +73,32 @@ describe("the official listing", () => { expect(listing.graphs[0].readme).toBe(""); }); + it("falls back to the raw URL when the contents API sends no inline content", async () => { + const fake = fakeFetch([ + STUBS(false)[0], + STUBS(false)[1], + { + url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, + status: 200, + // exactly what GitHub answers for a file over 1 MB + body: { name: "graph.json", path: "registry/graphs/hello-graph/graph.json", size: 2000000, content: "", encoding: "none" }, + }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 404, body: {} }, + { + url: "raw.githubusercontent.com/UniverLab/graphs/HEAD/registry/graphs/hello-graph/graph.json", + status: 200, + text: VALID_GRAPH_TEXT, + headers: { "content-type": "text/plain" }, + }, + { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const listing = (await listOfficial(github, () => undefined)) as OfficialListing; + expect(listing.graphs.length).toBe(1); + expect(listing.graphs[0].slug).toBe("hello-graph"); + expect(listing.graphs[0].readme).toBe(""); + }); + it("surfaces a rate limit from the directory listing as a typed error", async () => { const fake = fakeFetch([ { diff --git a/worker/test/search.test.ts b/worker/test/search.test.ts index de80307..9b185f4 100644 --- a/worker/test/search.test.ts +++ b/worker/test/search.test.ts @@ -173,6 +173,40 @@ describe("community search", () => { expect(outcome.items[0].readme).toBe(""); }); + it("skips a repository whose tree is unreadable and keeps the others", async () => { + const fake = fakeFetch([ + { + url: SEARCH, + status: 200, + body: { + total_count: 2, + items: [ + { name: "bad", owner: { login: "acme" }, default_branch: "main", stargazers_count: 1 }, + { name: "good", owner: { login: "acme" }, default_branch: "main", stargazers_count: 1 }, + ], + }, + }, + { url: "repos/acme/bad/git/trees/main", status: 200, text: "portal", headers: { "content-type": "text/html" } }, + { url: "repos/acme/good/git/trees/main", status: 200, body: { tree: [{ path: "graphs/flow/graph.json", type: "blob" }] } }, + { url: "repos/acme/good/contents/graphs/flow/graph.json", status: 200, body: fileBody("graphs/flow/graph.json", VALID_GRAPH_TEXT) }, + { url: "repos/acme/good/contents/graphs/flow/README.md", status: 404, body: {} }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const outcome = (await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined)) as SearchOutcome; + expect(outcome.items.length).toBe(1); + expect(outcome.items[0].repo).toBe("acme/good"); + }); + + it("turns an unreadable search body into a typed upstream error, never a throw", async () => { + const fake = fakeFetch([ + { url: SEARCH, status: 200, text: "portal", headers: { "content-type": "text/html" } }, + ]); + const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); + const outcome = await searchCommunity(github, { q: "", sort: "top", page: 1 }, () => undefined); + expect("kind" in outcome ? outcome.kind : "").toBe("upstream"); + expect("kind" in outcome ? outcome.status : 0).toBe(502); + }); + it("surfaces a rate limit from the search call as a typed error", async () => { const fake = fakeFetch([ { url: SEARCH, status: 429, headers: { "x-ratelimit-remaining": "0" } }, From 05b46fb7ec26b05d78b584e5621e7e2cc17f53b9 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 18:50:55 -0500 Subject: [PATCH 008/180] feat(site): render the catalogue pages in the lab's visual system --- worker/src/index.ts | 33 +- worker/src/pages/copy.ts | 122 ++ worker/src/pages/layout.ts | 95 ++ worker/src/pages/md.ts | 101 ++ worker/src/pages/ref.ts | 59 + worker/src/pages/routes.ts | 412 +++++++ worker/src/pages/svg.ts | 178 +++ worker/src/pages/theme.ts | 234 ++++ worker/test/__snapshots__/pages.test.ts.snap | 1064 ++++++++++++++++++ worker/test/pages-fixture.ts | 64 ++ worker/test/pages.test.ts | 291 +++++ 11 files changed, 2648 insertions(+), 5 deletions(-) create mode 100644 worker/src/pages/copy.ts create mode 100644 worker/src/pages/layout.ts create mode 100644 worker/src/pages/md.ts create mode 100644 worker/src/pages/ref.ts create mode 100644 worker/src/pages/routes.ts create mode 100644 worker/src/pages/svg.ts create mode 100644 worker/src/pages/theme.ts create mode 100644 worker/test/__snapshots__/pages.test.ts.snap create mode 100644 worker/test/pages-fixture.ts create mode 100644 worker/test/pages.test.ts diff --git a/worker/src/index.ts b/worker/src/index.ts index c64f574..e26f1ad 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,14 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 /** - * graphs.univerlab.org — install-URL redirector for catalogue documents. + * graphs.univerlab.org — the catalogue site: pages, API and install URLs. * * curl -fsSL https://graphs.univerlab.org/i/graph/hello-graph * canopy graph install hello-graph * - * It answers every request with either a 302 to the document on GitHub or a - * 400/404 explaining why not. The Worker never fetches or proxies the - * document: GitHub stays the download origin and the trust anchor, so this - * adds no supply-chain surface beyond a redirect. + * Three surfaces, one Worker: `/api/*` is the JSON API (api.ts), the + * catalogue pages (/, /search, /g, /n, /llms.txt) are the HTML/markdown + * renderer (pages/routes.ts), and everything else that is not an install + * URL falls through to the redirector below, which answers with either a + * 302 to the document on GitHub or a 400/404 explaining why not. The + * Worker never fetches or proxies the document: GitHub stays the download + * origin and the trust anchor, so this adds no supply-chain surface beyond + * a redirect. * * It DOES record one aggregate data point per request (Analytics Engine): * which document, which kind of client, the Canopy version when there is one, @@ -28,6 +32,7 @@ import { parseTarget, } from "./lib.js"; import { handleApi, type ApiEnv } from "./api.js"; +import { handlePage, NOT_PAGE } from "./pages/routes.js"; import { type CacheLike } from "./github.js"; /** The single Analytics Engine method this Worker uses. */ @@ -89,6 +94,17 @@ function record(env: Env, req: Request, parts: Parts, outcome: Outcome): void { } } +/** First path segment, without leading slashes. */ +function firstSegment(pathname: string): string { + return pathname.replace(/^\/+/, "").split("/")[0] ?? ""; +} + +/** The page routes: everything else stays the synchronous redirector. */ +function isPagePath(pathname: string): boolean { + const first = firstSegment(pathname); + return first === "" || first === "search" || first === "llms.txt" || first === "g" || first === "n"; +} + export default { fetch(req: Request, env: Env): Response | Promise { const url = new URL(req.url); @@ -97,6 +113,13 @@ export default { (answer) => answer === true ? install(req, env, url) : answer, ); } + // The catalogue pages claim /, /search, /g, /n and /llms.txt; every + // other path (the /i install routes above all) stays a redirect. + if (isPagePath(url.pathname)) { + return handlePage(req, env, { cache: defaultCache() }).then( + (answer) => answer === NOT_PAGE ? install(req, env, url) : answer, + ); + } return install(req, env, url); }, }; diff --git a/worker/src/pages/copy.ts b/worker/src/pages/copy.ts new file mode 100644 index 0000000..fd31e2d --- /dev/null +++ b/worker/src/pages/copy.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Every English string the catalogue pages render. + * + * The pages are template functions over this module: no other file in + * `pages/` holds page prose, so a Spanish version follows by swapping this + * one file. The few string *shapes* (an install command, an llms.txt line) + * live here too, next to the words they are made of. + */ +import type { DocumentKind } from "../../../src/schema.js"; +import type { Doc } from "../documents.js"; + +/** The site's name, in the header and the page titles. */ +export const SITE_NAME = "graphs.univerlab.org"; + +/** Suffix for ``: "hello-graph · graphs.univerlab.org". */ +export const TITLE_SEPARATOR = " · "; + +/** One-line tagline under the site name. */ +export const TAGLINE = "A catalogue of Canopy graphs and nodes."; + +/** The search field's visible label. */ +export const SEARCH_LABEL = "Search graphs and nodes"; + +/** The search field's placeholder. */ +export const SEARCH_PLACEHOLDER = "hello-graph"; + +/** The search form's submit button. */ +export const SEARCH_BUTTON = "Search"; + +/** Heading of the official listing. */ +export const OFFICIAL_HEADING = "Official"; + +/** The two tabs over community results. */ +export const TOP_TAB = "Top"; +export const NEW_TAB = "New"; + +/** Section headings for document lists. */ +export const GRAPHS_HEADING = "Graphs"; +export const NODES_HEADING = "Nodes"; + +/** Heading over the search results list. */ +export const RESULTS_HEADING = "Results"; + +/** Shown when a listing or a search came back empty. */ +export const EMPTY_LIST = "Nothing here yet."; + +/** Shown when a search matched nothing. */ +export const NO_RESULTS = "No graphs or nodes matched your search."; + +/** The document page's meta line. */ +export const REPO_LABEL = "Repository"; + +/** The "Star on GitHub" button. */ +export const STAR_BUTTON = "Star on GitHub"; + +/** Counts, shown only when present. */ +export const STARS_LABEL = "stars"; +export const DOWNLOADS_LABEL = "downloads"; + +/** The install section and the two command prefixes. */ +export const INSTALL_HEADING = "Install"; +export const INSTALL_GRAPH_PREFIX = "canopy graph install"; +export const INSTALL_NODE_PREFIX = "canopy node install"; + +/** The label of the install reference line in the markdown twin. */ +export const REF_LABEL = "ref"; + +/** The copy button; `data-copy` carries the command. */ +export const COPY_BUTTON = "Copy"; + +/** The copy button's accessible label. */ +export const COPY_ARIA = "Copy the install command to the clipboard"; + +/** The drawing's heading and its accessible description. */ +export const DRAWING_HEADING = "Drawing"; +export const DRAWING_ALT = "A drawing of the graph: nodes left to right by layer."; + +/** The agent-nodes section: these are the nodes a harness must run. */ +export const AGENT_NODES_HEADING = "Agent nodes"; +export const AGENT_NODES_NOTE = "These agent nodes need a harness to run."; + +/** The README section heading. */ +export const README_HEADING = "README"; + +/** The footer line, linking to the Canopy page on univerlab.org. */ +export const FOOTER_TEXT = "Canopy · UniverLab"; +export const FOOTER_HREF = "https://univerlab.org/canopy/"; + +/** The theme toggle's accessible label. */ +export const THEME_TOGGLE = "Switch between day and night"; + +/** The calm 503: GitHub (or its rate limit) failed the data layer. */ +export const BUSY_HEADING = "GitHub is busy"; +export const BUSY_BODY = "The catalogue could not reach GitHub right now. Please try again in a moment."; + +/** The calm 404: no such document. */ +export const NOT_FOUND_HEADING = "Not found"; +export const NOT_FOUND_BODY = "There is no document at this address. It may have moved or never existed."; + +/** The llms.txt header line, above one line per document. */ +export const LLMS_HEADER = "# graphs.univerlab.org — the Canopy graph and node catalogue."; + +/** Search results announce their query through this heading. */ +export function resultsFor(q: string): string { + return q === "" ? RESULTS_HEADING : `${RESULTS_HEADING} for “${q}”`; +} + +/** The install command for a document of `kind`. */ +export function installCommand(kind: DocumentKind, ref: string): string { + return `${kind === "graph" ? INSTALL_GRAPH_PREFIX : INSTALL_NODE_PREFIX} ${ref}`; +} + +/** One llms.txt line: name, description, ref — tab-separated, one per doc. */ +export function llmsLine(doc: Doc, ref: string): string { + return `${doc.name}\t${doc.description}\t${ref}`; +} + +/** How a count reads on a document page: "12 stars". */ +export function countLine(label: string, count: number): string { + return `${count} ${label}`; +} diff --git a/worker/src/pages/layout.ts b/worker/src/pages/layout.ts new file mode 100644 index 0000000..41454b4 --- /dev/null +++ b/worker/src/pages/layout.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The page shell: one wrapper every route renders through. + * + * A route builds a `Page` — a title, a meta description, the HTML body and + * its markdown twin — and `page()` answers the request with one or the + * other: a request sending `Accept: text/markdown` gets the twin (content + * type `text/markdown`), everything else the full HTML document with the + * theme, fonts and footer of univerlab.org's visual system. + */ +import { SITE_NAME, TAGLINE, TITLE_SEPARATOR, FOOTER_HREF, FOOTER_TEXT } from "./copy.js"; +import { escapeHtml } from "./md.js"; +import { + copyScript, + fontLinks, + themeCss, + themeScript, + themeToggle, +} from "./theme.js"; + +/** What one route renders: HTML for browsers, markdown for agents. */ +export interface Page { + title: string; + description: string; + html: string; + markdown: string; +} + +/** True when the request prefers the markdown twin over HTML. */ +export function wantsMarkdown(req: Request): boolean { + const accept = req.headers.get("accept") ?? ""; + return accept.includes("text/markdown"); +} + +function htmlDocument(page: Page): string { + const title = escapeHtml(`${page.title}${TITLE_SEPARATOR}${SITE_NAME}`); + const description = escapeHtml(page.description); + return [ + `<!doctype html>`, + `<html lang="en">`, + `<head>`, + `<meta charset="utf-8">`, + `<meta name="viewport" content="width=device-width, initial-scale=1">`, + `<title>${title}`, + ``, + fontLinks(), + ``, + ``, + ``, + ``, + ``, + `

${escapeHtml(TAGLINE)}

`, + `
`, + page.html, + `
`, + ``, + ``, + ``, + ``, + ``, + ].join("\n"); +} + +/** + * Answer a page request: the markdown twin when asked for, the HTML shell + * otherwise. Both carry a short public cache, like the JSON API's. + */ +export function page(req: Request, content: Page, status = 200): Response { + const cacheControl = status === 200 ? "public, max-age=300" : "no-store"; + if (wantsMarkdown(req)) { + return new Response(content.markdown, { + status, + headers: { + "content-type": "text/markdown; charset=utf-8", + "cache-control": cacheControl, + }, + }); + } + return new Response(htmlDocument(content), { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": cacheControl, + }, + }); +} diff --git a/worker/src/pages/md.ts b/worker/src/pages/md.ts new file mode 100644 index 0000000..cf9545e --- /dev/null +++ b/worker/src/pages/md.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * A minimal markdown renderer for READMEs, safe by construction. + * + * Supported: headings, paragraphs, fenced code blocks, links and inline + * code — nothing else. Everything the renderer does not recognise is + * escaped, so HTML in a README never passes through as HTML; the only tags + * in the output are the ones this file emits. Links carry `rel="nofollow"` + * and only http(s), relative and anchor targets survive; anything else + * renders as its text. No dependency: this is the whole sanitizer. + */ + +/** Escape text for HTML content and attribute values alike. */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** Only these link targets become anchors; the rest degrade to text. */ +const SAFE_URL = /^(?:https?:\/\/|\/|#|\.\.?\/)/i; + +/** Inline pieces this renderer understands, scanned left to right. */ +const INLINE_RE = /(`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g; + +function renderInline(raw: string): string { + let html = ""; + let cursor = 0; + for (const match of raw.matchAll(INLINE_RE)) { + const piece = match[0]; + const start = match.index ?? 0; + html += escapeHtml(raw.slice(cursor, start)); + cursor = start + piece.length; + if (piece.startsWith("`")) { + html += `${escapeHtml(piece.slice(1, -1))}`; + continue; + } + const close = piece.indexOf("]("); + const label = piece.slice(1, close); + const target = piece.slice(close + 2, -1); + if (SAFE_URL.test(target)) { + html += `${renderInline(label)}`; + } else { + html += renderInline(label); + } + } + return html + escapeHtml(raw.slice(cursor)); +} + +const HEADING_RE = /^(#{1,6})\s+(.*)$/; +const FENCE_RE = /^```/; + +/** Render markdown to escaped, sanitized HTML. Never throws. */ +export function renderMarkdown(markdown: string): string { + const blocks: string[] = []; + const paragraphs: string[] = []; + const lines = markdown.replaceAll("\r\n", "\n").split("\n"); + let index = 0; + + const flushParagraph = (): void => { + if (paragraphs.length > 0) { + blocks.push(`

${renderInline(paragraphs.join("\n"))}

`); + paragraphs.length = 0; + } + }; + + while (index < lines.length) { + const line = lines[index] ?? ""; + if (FENCE_RE.test(line.trim())) { + flushParagraph(); + const code: string[] = []; + index += 1; + while (index < lines.length && !FENCE_RE.test((lines[index] ?? "").trim())) { + code.push(lines[index] ?? ""); + index += 1; + } + index += 1; + blocks.push(`
${escapeHtml(code.join("\n"))}
`); + continue; + } + const heading = HEADING_RE.exec(line); + if (heading !== null) { + flushParagraph(); + const level = heading[1]?.length ?? 1; + blocks.push(`${renderInline((heading[2] ?? "").trim())}`); + index += 1; + continue; + } + if (line.trim() === "") { + flushParagraph(); + index += 1; + continue; + } + paragraphs.push(line); + index += 1; + } + flushParagraph(); + return blocks.join("\n"); +} diff --git a/worker/src/pages/ref.ts b/worker/src/pages/ref.ts new file mode 100644 index 0000000..3cb92dc --- /dev/null +++ b/worker/src/pages/ref.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The reference a document installs by. + * + * Official documents install by slug alone — the registry repository is + * implicit — while community documents install by `owner/repo/slug`. This + * module is the one place that decides, and it reuses the data layer's own + * notion of "official" (`isOfficialRepo`) so a page and an install command + * can never disagree. + */ +import type { DocumentKind } from "../../../src/schema.js"; +import type { Doc } from "../documents.js"; +import { isOfficialRepo } from "../lib.js"; + +export interface PageRef { + kind: DocumentKind; + official: boolean; + owner: string; + repo: string; + slug: string; + /** What the install command takes: the slug, or `owner/repo/slug`. */ + ref: string; + /** Where the repository lives, for the repo link and the star button. */ + repoUrl: string; +} + +/** The reference for a document of `kind` in `owner/repo`. */ +export function pageRef(kind: DocumentKind, owner: string, repo: string, slug: string): PageRef { + const official = isOfficialRepo(owner, repo); + return { + kind, + official, + owner, + repo, + slug, + ref: official ? slug : `${owner}/${repo}/${slug}`, + repoUrl: `https://github.com/${owner}/${repo}`, + }; +} + +/** + * The catalogue path for a document. Official documents use the short + * form; community documents carry the repository. + */ +export function pagePath(ref: PageRef): string { + const verb = ref.kind === "graph" ? "g" : "n"; + return ref.official ? `/${verb}/${ref.slug}` : `/${verb}/${ref.owner}/${ref.repo}/${ref.slug}`; +} + +/** The reference of a listed document, whose repo is the `owner/repo` pair. */ +export function docRef(doc: Doc): PageRef { + const [owner = "", repo = ""] = doc.repo.split("/"); + return pageRef(doc.kind, owner, repo, doc.slug); +} + +/** The catalogue path of a listed document. */ +export function docPath(doc: Doc): string { + return pagePath(docRef(doc)); +} diff --git a/worker/src/pages/routes.ts b/worker/src/pages/routes.ts new file mode 100644 index 0000000..fb3280e --- /dev/null +++ b/worker/src/pages/routes.ts @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The HTML pages of graphs.univerlab.org, and their markdown twins. + * + * GET / search box, Official first, Top and New tabs + * GET /search?q=&sort=top|new community results + * GET /g/ official graph page + * GET /g/// community graph page + * GET /n/, /n/// node pages likewise + * GET /llms.txt one line per document: name, description, ref + * + * Every route reads the catalogue through the data layer (official.ts, + * documents.ts, search.ts) and only renders what comes back. A typed + * data-layer error answers a calm page with status 503, a missing + * document a calm 404, and nothing here ever shows a stack trace. Any + * path this router does not claim answers NOT_PAGE, so index.ts keeps the + * install redirector exactly as it was. + */ +import { type ApiDeps, type ApiEnv } from "../api.js"; +import { attachDownloads, createAnalytics, type Downloads } from "../analytics.js"; +import { type Log, createGitHub, isApiError } from "../github.js"; +import { OFFICIAL_OWNER, OFFICIAL_REPO, OWNER_RE, REPO_RE, SLUG_RE } from "../lib.js"; +import type { DocumentKind, JsonValue } from "../../../src/schema.js"; +import type { Doc } from "../documents.js"; +import { documentDirectory, loadDocument } from "../documents.js"; +import { listOfficial, loadOfficialDocument } from "../official.js"; +import { type SearchSort, searchCommunity } from "../search.js"; +import { isArray, isRecord, isString, stringField } from "../json.js"; +import { + AGENT_NODES_HEADING, + AGENT_NODES_NOTE, + BUSY_BODY, + BUSY_HEADING, + DOWNLOADS_LABEL, + DRAWING_HEADING, + EMPTY_LIST, + GRAPHS_HEADING, + INSTALL_HEADING, + LLMS_HEADER, + NEW_TAB, + NO_RESULTS, + NODES_HEADING, + NOT_FOUND_BODY, + NOT_FOUND_HEADING, + OFFICIAL_HEADING, + README_HEADING, + REPO_LABEL, + REF_LABEL, + SEARCH_BUTTON, + SEARCH_LABEL, + SEARCH_PLACEHOLDER, + SITE_NAME, + STARS_LABEL, + STAR_BUTTON, + TAGLINE, + TOP_TAB, + countLine, + installCommand, + llmsLine, + resultsFor, +} from "./copy.js"; +import { page, type Page } from "./layout.js"; +import { escapeHtml, renderMarkdown } from "./md.js"; +import { docPath, docRef, pageRef, type PageRef } from "./ref.js"; +import { renderGraph } from "./svg.js"; +import { copyButton } from "./theme.js"; + +/** A non-page request: index.ts keeps routing it to the redirector. */ +export const NOT_PAGE = true as const; + +/** The environment the pages read: the API's three optional values. */ +export type PageEnv = ApiEnv; + +/** Dependency injection for tests; identical to the API's. */ +export type PageDeps = ApiDeps; + +const NO_LOG: Log = () => undefined; + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} + +function splitPath(pathname: string): string[] { + const trimmed = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + if (trimmed === "") { + return []; + } + return trimmed.split("/").map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); +} + +/** The pages' GitHub transport and analytics, wired like the API's. */ +function readers(env: PageEnv, deps: PageDeps) { + const log = deps.log ?? NO_LOG; + const github = createGitHub({ + fetchImpl: deps.fetchImpl, + cache: deps.cache, + token: env.GITHUB_TOKEN, + log, + }); + const volumes = async (): Promise => + createAnalytics({ + fetchImpl: deps.fetchImpl, + cache: deps.cache, + accountId: env.CF_ACCOUNT_ID, + token: env.CF_ANALYTICS_TOKEN, + log, + }).volume(); + return { github, log, volumes }; +} + +/** The calm 503: the data layer failed, and the page says so, calmly. */ +function busy(req: Request): Response { + return page(req, calmPage(BUSY_HEADING, BUSY_BODY), 503); +} + +/** The calm 404: no document at this address. */ +function notFound(req: Request): Response { + return page(req, calmPage(NOT_FOUND_HEADING, NOT_FOUND_BODY), 404); +} + +function calmPage(heading: string, body: string): Page { + return { + title: heading, + description: body, + html: `

${escapeHtml(heading)}

${escapeHtml(body)}

`, + markdown: `# ${heading}\n\n${body}\n`, + }; +} + +/** The agent nodes a harness will have to run; empty for node documents. */ +function agentNames(document: unknown): string[] { + if (!isRecord(document) || !isArray(document["nodes"])) { + return []; + } + return document["nodes"].flatMap((node) => + isRecord(node) && stringField(node, "kind") === "agent" && isString(node["name"]) + ? [node["name"]] + : [], + ); +} + +function searchForm(q: string): string { + return ( + `` + ); +} + +function searchHref(q: string, sort: SearchSort): string { + const base = q === "" ? "" : `?q=${encodeURIComponent(q)}&`; + return `/search${base}sort=${sort}`; +} + +function tabs(q: string, active: SearchSort | null): string { + const tab = (sort: SearchSort, label: string): string => + `${label}`; + return ``; +} + +function docItem(doc: Doc): string { + const stats: string[] = []; + if (doc.stars !== null) { + stats.push(countLine(STARS_LABEL, doc.stars)); + } + if (doc.downloads !== undefined) { + stats.push(countLine(DOWNLOADS_LABEL, doc.downloads)); + } + const meta = stats.length === 0 ? "" : ` — ${escapeHtml(stats.join(" · "))}`; + return ( + `
  • ${escapeHtml(doc.name)}${meta}` + + `${escapeHtml(doc.description)}
  • ` + ); +} + +function docList(docs: Doc[]): string { + if (docs.length === 0) { + return `

    ${EMPTY_LIST}

    `; + } + return `
      ${docs.map(docItem).join("")}
    `; +} + +/** Backslash the markdown punctuation one line of text may carry. */ +function mdText(text: string): string { + return text.replace(/([[\]])/g, "\\$1"); +} + +function docListMarkdown(docs: Doc[]): string { + if (docs.length === 0) { + return `${EMPTY_LIST}\n`; + } + const lines = docs.map( + (doc) => `- [${mdText(doc.name)}](${docPath(doc)}) — ${mdText(doc.description)} — ${docRef(doc).ref}`, + ); + return `${lines.join("\n")}\n`; +} + +/** One document page: name, meta, star button, install, drawing, README. */ +function documentPage(doc: Doc, document: JsonValue, ref: PageRef): Page { + const install = installCommand(ref.kind, ref.ref); + const stats: string[] = []; + if (doc.stars !== null) { + stats.push(countLine(STARS_LABEL, doc.stars)); + } + if (doc.downloads !== undefined) { + stats.push(countLine(DOWNLOADS_LABEL, doc.downloads)); + } + const meta = stats.length === 0 ? "" : ` — ${escapeHtml(stats.join(" · "))}`; + const agents = ref.kind === "graph" ? agentNames(document) : []; + + const html = [ + `

    ${escapeHtml(doc.name)}

    `, + `

    ${escapeHtml(doc.description)}

    `, + `

    ${escapeHtml(doc.repo)}${meta}

    `, + `

    ${STAR_BUTTON}

    `, + `

    ${INSTALL_HEADING}

    `, + `
    ${escapeHtml(install)}${copyButton(install)}
    `, + ref.kind === "graph" ? `

    ${DRAWING_HEADING}

    ${renderGraph(document, ref.ref)}` : "", + agents.length === 0 + ? "" + : `

    ${AGENT_NODES_HEADING}

    ${escapeHtml(agents.join(", "))}

    ` + + `

    ${AGENT_NODES_NOTE}

    `, + `

    ${README_HEADING}

    `, + `
    ${renderMarkdown(doc.readme)}
    `, + ].join("\n"); + + const markdown = [ + `# ${doc.name}`, + ``, + doc.description, + ``, + `- ${REPO_LABEL}: ${ref.repoUrl}`, + `- ${REF_LABEL}: ${ref.ref}`, + ...stats.map((stat) => `- ${stat}`), + ``, + `## ${INSTALL_HEADING}`, + ``, + ` ${install}`, + ``, + agents.length === 0 + ? "" + : `## ${AGENT_NODES_HEADING}\n\n${agents.join(", ")}\n\n${AGENT_NODES_NOTE}\n`, + `## ${README_HEADING}`, + ``, + doc.readme.endsWith("\n") || doc.readme === "" ? doc.readme : `${doc.readme}\n`, + ].join("\n"); + + return { title: doc.name, description: doc.description, html, markdown }; +} + +function homePage(graphs: Doc[], nodes: Doc[]): Page { + const html = [ + searchForm(""), + tabs("", null), + `

    ${OFFICIAL_HEADING}

    `, + `

    ${GRAPHS_HEADING}

    `, + docList(graphs), + `

    ${NODES_HEADING}

    `, + docList(nodes), + ].join("\n"); + const markdown = [ + `# ${SITE_NAME}`, + ``, + TAGLINE, + ``, + `## ${OFFICIAL_HEADING}`, + ``, + `### ${GRAPHS_HEADING}`, + ``, + docListMarkdown(graphs), + `### ${NODES_HEADING}`, + ``, + docListMarkdown(nodes), + ].join("\n"); + return { title: SITE_NAME, description: TAGLINE, html, markdown }; +} + +function searchPage(q: string, sort: SearchSort, items: Doc[]): Page { + const heading = resultsFor(q); + const html = [ + searchForm(q), + tabs(q, sort), + `

    ${escapeHtml(heading)}

    `, + items.length === 0 ? `

    ${NO_RESULTS}

    ` : docList(items), + ].join("\n"); + const markdown = [ + `# ${heading}`, + ``, + `## ${TOP_TAB} / ${NEW_TAB}`, + ``, + items.length === 0 ? `${NO_RESULTS}\n` : docListMarkdown(items), + ].join("\n"); + return { title: heading, description: TAGLINE, html, markdown }; +} + +/** The document route: load through the data layer, then render. */ +async function documentResponse(req: Request, env: PageEnv, deps: PageDeps, ref: PageRef): Promise { + const { github, log, volumes } = readers(env, deps); + const loaded = ref.official + ? await loadOfficialDocument(github, ref.kind, ref.slug, log) + : await loadDocument( + github, + { + owner: ref.owner, + repo: ref.repo, + directory: documentDirectory(ref.owner, ref.repo, ref.kind, ref.slug), + kind: ref.kind, + slug: ref.slug, + stars: null, + }, + log, + ); + // The load result is a tagged union, not an ApiError: "doc" wins first. + if (loaded.kind === "absent") { + return notFound(req); + } + if (loaded.kind !== "doc") { + return busy(req); + } + const [doc] = attachDownloads([loaded.entry.doc], await volumes()); + return page(req, documentPage(doc, loaded.entry.document, ref)); +} + +async function homeResponse(req: Request, env: PageEnv, deps: PageDeps): Promise { + const { github, log, volumes } = readers(env, deps); + const listing = await listOfficial(github, log); + if (isApiError(listing)) { + return busy(req); + } + const counts = await volumes(); + return page(req, homePage(attachDownloads(listing.graphs, counts), attachDownloads(listing.nodes, counts))); +} + +async function searchResponse(req: Request, url: URL, env: PageEnv, deps: PageDeps): Promise { + const q = (url.searchParams.get("q") ?? "").slice(0, 256); + const sort: SearchSort = url.searchParams.get("sort") === "new" ? "new" : "top"; + const { github, log, volumes } = readers(env, deps); + const outcome = await searchCommunity(github, { q, sort, page: 1 }, log); + if (isApiError(outcome)) { + return busy(req); + } + return page(req, searchPage(q, sort, attachDownloads(outcome.items, await volumes()))); +} + +/** llms.txt: plain text, one tab-separated line per official document. */ +async function llmsResponse(env: PageEnv, deps: PageDeps): Promise { + const { github, log, volumes } = readers(env, deps); + const listing = await listOfficial(github, log); + if (isApiError(listing)) { + return textResponse(`${BUSY_BODY}\n`, 503); + } + const docs = attachDownloads([...listing.graphs, ...listing.nodes], await volumes()); + const lines = [LLMS_HEADER, ""]; + for (const doc of docs) { + lines.push(llmsLine(doc, docRef(doc).ref)); + } + return textResponse(`${lines.join("\n")}\n`, 200); +} + +/** + * The page router. A Response for every page route, NOT_PAGE for anything + * else (the install paths, unknown shapes) so index.ts decides. + */ +export async function handlePage( + req: Request, + env: PageEnv, + deps: PageDeps = {}, +): Promise { + if (req.method !== "GET") { + return NOT_PAGE; + } + const url = new URL(req.url); + const segments = splitPath(url.pathname); + if (segments.length === 0) { + return homeResponse(req, env, deps); + } + if (segments.length === 1 && segments[0] === "search") { + return searchResponse(req, url, env, deps); + } + if (segments.length === 1 && segments[0] === "llms.txt") { + return llmsResponse(env, deps); + } + const verb = segments[0]; + if ((verb === "g" || verb === "n") && (segments.length === 2 || segments.length === 4)) { + const kind: DocumentKind = verb === "g" ? "graph" : "node"; + const parts = segments.slice(1); + const slug = parts[parts.length - 1] ?? ""; + if (parts.length === 1) { + return SLUG_RE.test(slug) + ? documentResponse(req, env, deps, pageRef(kind, OFFICIAL_OWNER, OFFICIAL_REPO, slug)) + : notFound(req); + } + const owner = parts[0] ?? ""; + const repo = parts[1] ?? ""; + return OWNER_RE.test(owner) && REPO_RE.test(repo) && SLUG_RE.test(slug) + ? documentResponse(req, env, deps, pageRef(kind, owner, repo, slug)) + : notFound(req); + } + return NOT_PAGE; +} diff --git a/worker/src/pages/svg.ts b/worker/src/pages/svg.ts new file mode 100644 index 0000000..1e82e41 --- /dev/null +++ b/worker/src/pages/svg.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The drawing of a graph, as inline SVG in currentColor. + * + * Nodes are labelled boxes whose class carries their kind; edges follow + * the document's conditions — `fail` edges dashed, everything else solid. + * The layout is deliberately simple: a longest-path layering (relaxation + * capped at one pass per node, so a cycle terminates) puts nodes in + * left-to-right columns, and within a column each node takes the next row. + * The whole drawing is static SVG: no script, no external anything. + */ +import type { JsonValue } from "../../../src/schema.js"; +import { isArray, isRecord, isString, stringField } from "../json.js"; +import { DRAWING_ALT } from "./copy.js"; +import { escapeHtml } from "./md.js"; + +/** Geometry in viewBox units: one box size, one column gap, one row height. */ +const BOX_W = 150; +const BOX_H = 36; +const GAP_X = 70; +const ROW_H = 52; +const PAD = 16; + +/** Labels longer than this are truncated; the box has a full-name title. */ +const LABEL_MAX = 20; + +interface DrawNode { + name: string; + kind: string; +} + +interface DrawEdge { + from: string; + to: string; + condition: string; +} + +function nodesOf(document: Record): DrawNode[] { + const nodes = document["nodes"]; + if (!isArray(nodes)) { + return []; + } + return nodes.flatMap((node) => { + if (!isRecord(node) || !isString(node["name"])) { + return []; + } + const kind = stringField(node, "kind") || "node"; + return [{ name: node["name"], kind: kind.replace(/[^a-z0-9-]/gi, "-").toLowerCase() }]; + }); +} + +function edgesOf(document: Record): DrawEdge[] { + const edges = document["edges"]; + if (!isArray(edges)) { + return []; + } + return edges.flatMap((edge) => { + if (!isRecord(edge) || !isString(edge["from_node"]) || !isString(edge["to_node"])) { + return []; + } + const condition = edge["condition"]; + const label = isString(condition) + ? condition + : isRecord(condition) && isString(condition["route"]) + ? `route:${condition["route"]}` + : "always"; + return [{ from: edge["from_node"], to: edge["to_node"], condition: label }]; + }); +} + +/** + * Longest-path layers, left to right: a Kahn sweep assigns each node one + * column past its deepest processed predecessor. Cycles (a fail edge back + * to an earlier node) would keep every node waiting, so when the sweep + * stalls it breaks the deadlock at the first unprocessed node in document + * order and treats it as a root — deterministic, and every node is placed + * exactly once. + */ +function layersOf(nodes: DrawNode[], edges: DrawEdge[]): number[] { + const known = new Set(nodes.map((node) => node.name)); + const layer = new Map(nodes.map((node) => [node.name, 0])); + const pending = new Map( + nodes.map((node) => [node.name, edges.filter((edge) => edge.to === node.name).length]), + ); + const done = new Set(); + while (done.size < nodes.length) { + const ready = nodes.find((node) => !done.has(node.name) && (pending.get(node.name) ?? 0) === 0); + const next = ready ?? nodes.find((node) => !done.has(node.name)); + if (next === undefined) { + break; + } + done.add(next.name); + for (const edge of edges) { + if (edge.from !== next.name || !known.has(edge.to)) { + continue; + } + pending.set(edge.to, Math.max(0, (pending.get(edge.to) ?? 0) - 1)); + if (done.has(edge.to)) { + continue; + } + const deeper = (layer.get(next.name) ?? 0) + 1; + if (deeper > (layer.get(edge.to) ?? 0)) { + layer.set(edge.to, deeper); + } + } + } + return nodes.map((node) => layer.get(node.name) ?? 0); +} + +function truncate(name: string): string { + return name.length > LABEL_MAX ? `${name.slice(0, LABEL_MAX - 1)}…` : name; +} + +/** + * Render a validated graph document as inline SVG. `ref` names the graph + * in the drawing's accessible label. An unreadable document simply draws + * nothing: the pages only ever pass validated graphs. + */ +export function renderGraph(document: JsonValue, ref: string): string { + if (!isRecord(document)) { + return ""; + } + const nodes = nodesOf(document); + const edges = edgesOf(document); + if (nodes.length === 0) { + return ""; + } + const layers = layersOf(nodes, edges); + const rows = new Map(); + const placed = nodes.map((node, index) => { + const column = layers[index] ?? 0; + const row = rows.get(column) ?? 0; + rows.set(column, row + 1); + return { + ...node, + column, + x: PAD + column * (BOX_W + GAP_X), + y: PAD + row * ROW_H, + }; + }); + const width = PAD * 2 + (Math.max(...placed.map((node) => node.column)) + 1) * BOX_W + + Math.max(...placed.map((node) => node.column)) * GAP_X; + const height = PAD * 2 + Math.max(...Array.from(rows.values())) * ROW_H - (ROW_H - BOX_H); + const at = new Map(placed.map((node) => [node.name, node])); + + const shapes = placed.map((node) => + `` + + `` + + `` + + `${escapeHtml(truncate(node.name))}` + + ``, + ); + + const lines = edges.flatMap((edge) => { + const from = at.get(edge.from); + const to = at.get(edge.to); + if (from === undefined || to === undefined) { + return []; + } + const condition = edge.condition === "fail" ? "fail" : edge.condition === "pass" ? "pass" : "other"; + const x1 = from.x + BOX_W; + const y1 = from.y + BOX_H / 2; + const x2 = to.x; + const y2 = to.y + BOX_H / 2; + return [ + ``, + ]; + }); + + return ( + `` + + lines.join("") + + shapes.join("") + + `` + ); +} diff --git a/worker/src/pages/theme.ts b/worker/src/pages/theme.ts new file mode 100644 index 0000000..1535cfe --- /dev/null +++ b/worker/src/pages/theme.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The visual system of univerlab.org for the catalogue pages. + * + * Google Fonts (Old Standard TT for display, EB Garamond for body, Sometype + * Mono for labels and commands), one inline stylesheet over CSS variables, + * and two tiny inline scripts: the theme (localStorage, else the visitor's + * local hour — 06-18 is day) and the copy buttons. No framework, no build + * step: what this module returns is exactly what the page ships. + */ +import { COPY_ARIA, COPY_BUTTON, THEME_TOGGLE } from "./copy.js"; +import { escapeHtml } from "./md.js"; + +/** The Google Fonts link: the three families, one request, swap on load. */ +export function fontLinks(): string { + return [ + ``, + ``, + ``, + ].join("\n"); +} + +/** Day and night as the two value sets behind the CSS variables. */ +export const THEME = { + day: { background: "#f4f0ea", ink: "#1d1510" }, + night: { background: "#222928", ink: "#ecebe4" }, + accent: "#3fb8b0", +} as const; + +const DISPLAY = `"Old Standard TT", Georgia, serif`; +const BODY = `"EB Garamond", Georgia, serif`; +const MONO = `"Sometype Mono", "Courier New", monospace`; + +/** + * The whole stylesheet, inline. `:root` is day; `html[data-theme="night"]` + * overrides the two variables that change. Reduced motion is respected by + * turning every transition off. + */ +export function themeCss(): string { + const { day, night, accent } = THEME; + return ` +:root { + --bg: ${day.background}; + --ink: ${day.ink}; + --accent: ${accent}; + --display: ${DISPLAY}; + --body: ${BODY}; + --mono: ${MONO}; +} +html[data-theme="night"] { + --bg: ${night.background}; + --ink: ${night.ink}; +} +* { box-sizing: border-box; } +body { + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; + max-width: 46rem; + background: var(--bg); + color: var(--ink); + font-family: var(--body); + font-size: 1.0625rem; + line-height: 1.55; +} +a { color: var(--accent); } +a:hover, a:focus-visible { text-decoration-thickness: 2px; } +.site-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} +.brand { + font-family: var(--display); + font-size: 1.5rem; + color: var(--ink); + text-decoration: none; +} +.tagline { margin: 0.25rem 0 1.5rem; font-style: italic; } +h1, h2, h3 { font-family: var(--display); line-height: 1.2; } +h1 { font-size: 2rem; margin: 0 0 0.25rem; } +h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; } +.description { margin: 0 0 1.25rem; font-size: 1.15rem; } +.readme h1 { font-size: 1.6rem; } +.readme h2 { font-size: 1.25rem; } +.readme pre { + overflow-x: auto; + padding: 0.75rem; + border: 1px solid var(--ink); + border-radius: 4px; + font-family: var(--mono); + font-size: 0.85rem; +} +.readme code { font-family: var(--mono); font-size: 0.9em; } +.readme img { max-width: 100%; } +.meta { font-family: var(--mono); font-size: 0.85rem; margin: 0.25rem 0 1rem; } +.tabs { display: flex; gap: 1rem; margin: 1rem 0; font-family: var(--mono); } +.tabs a[aria-current="page"] { color: var(--ink); text-decoration-thickness: 2px; } +.search { display: flex; gap: 0.5rem; align-items: end; margin: 1rem 0 2rem; } +.search label { display: block; font-family: var(--mono); font-size: 0.85rem; } +.search input { + min-width: 14rem; + padding: 0.4rem 0.5rem; + background: var(--bg); + color: var(--ink); + border: 1px solid var(--ink); + border-radius: 4px; + font-family: var(--body); + font-size: 1rem; +} +.doc-list { list-style: none; padding: 0; margin: 0; } +.doc-list li { margin: 0.75rem 0; } +.doc-list .doc-name { font-family: var(--display); font-size: 1.15rem; } +.doc-list .doc-description { display: block; } +.button { + display: inline-block; + padding: 0.35rem 0.75rem; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--bg); + background: var(--accent); + font-family: var(--mono); + font-size: 0.9rem; + text-decoration: none; + cursor: pointer; +} +.install { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin: 0.5rem 0 1rem; +} +.install code { + padding: 0.4rem 0.6rem; + border: 1px solid var(--ink); + border-radius: 4px; + font-family: var(--mono); + font-size: 0.9rem; + overflow-x: auto; +} +.copy { + padding: 0.35rem 0.75rem; + border: 1px solid var(--ink); + border-radius: 4px; + background: var(--bg); + color: var(--ink); + font-family: var(--mono); + font-size: 0.9rem; + cursor: pointer; +} +.copy:hover, .copy:focus-visible { border-color: var(--accent); } +.theme-toggle { + padding: 0.2rem 0.6rem; + border: 1px solid var(--ink); + border-radius: 4px; + background: var(--bg); + color: var(--ink); + font-family: var(--mono); + font-size: 0.85rem; + cursor: pointer; +} +.graph-svg { max-width: 100%; height: auto; margin: 1rem 0; color: var(--ink); } +.graph-svg .node rect { fill: var(--bg); stroke: currentColor; stroke-width: 1.25; } +.graph-svg .node text { font-family: var(--mono); font-size: 12px; fill: currentColor; } +.graph-svg .edge { stroke: currentColor; stroke-width: 1.25; } +.graph-svg .edge.fail { stroke-dasharray: 6 4; } +.agent-nodes { font-family: var(--mono); font-size: 0.95rem; } +.site-footer { margin-top: 3rem; font-family: var(--mono); font-size: 0.85rem; } +.busy { border-left: 3px solid var(--accent); padding-left: 1rem; } +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} +`.trim(); +} + +/** + * The theme script, inline in `` so the ground never flashes: the + * stored choice wins, else the local hour decides (06-18 is day). The + * toggle flips the theme and stores it. + */ +export function themeScript(): string { + return ( + `(function(){` + + `var stored=null;try{stored=localStorage.getItem("theme")}catch(e){}` + + `var hour=new Date().getHours();` + + `var theme=stored||(hour>=6&&hour<18?"day":"night");` + + `document.documentElement.dataset.theme=theme;` + + `document.addEventListener("DOMContentLoaded",function(){` + + `var button=document.getElementById("theme-toggle");` + + `if(!button){return}` + + `button.addEventListener("click",function(){` + + `var next=document.documentElement.dataset.theme==="day"?"night":"day";` + + `document.documentElement.dataset.theme=next;` + + `try{localStorage.setItem("theme",next)}catch(e){}` + + `});` + + `});` + + `})();` + ); +} + +/** The theme toggle button, in the header. */ +export function themeToggle(): string { + return ``; +} + +/** + * The copy script, inline at the end of ``: one delegated listener + * for every `.copy` button, so the buttons stay plain, focusable + * `type="button"` elements and the keyboard reaches them for free. + */ +export function copyScript(): string { + return ( + `(function(){` + + `document.addEventListener("click",function(event){` + + `var target=event.target;` + + `if(!target||!target.classList||!target.classList.contains("copy")){return}` + + `var command=target.getAttribute("data-copy")||"";` + + `if(navigator.clipboard){navigator.clipboard.writeText(command)}` + + `var original=target.textContent;` + + `target.textContent=command;` + + `window.setTimeout(function(){target.textContent=original},1500);` + + `});` + + `})();` + ); +} + +/** One copy button for an install command. */ +export function copyButton(command: string): string { + return ``; +} diff --git a/worker/test/__snapshots__/pages.test.ts.snap b/worker/test/__snapshots__/pages.test.ts.snap new file mode 100644 index 0000000..0cee3ef --- /dev/null +++ b/worker/test/__snapshots__/pages.test.ts.snap @@ -0,0 +1,1064 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`route snapshots > renders / with the official listing first 1`] = ` +" + + + + +graphs.univerlab.org · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    + + +

    Official

    +

    Graphs

    +
    • hello-graph — 7 starsFive nodes with one fail edge, for the drawing test.
    +

    Nodes

    +
    • hello-node — 7 starsExample catalogue node: drafts an implementation plan.
    +
    + + + + +" +`; + +exports[`route snapshots > renders /g/// with the community ref 1`] = ` +" + + + + +hello-graph · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    +

    hello-graph

    +

    Minimal example graph: implement, check, gate, and a router with two routes.

    +

    acme/things

    +

    Star on GitHub

    +

    Install

    +
    canopy graph install acme/things/flow
    +

    Drawing

    implementertriagetestsgate +

    Agent nodes

    implementer

    These agent nodes need a harness to run.

    +

    README

    +
    +
    + + + + +" +`; + +exports[`route snapshots > renders /g/ with the drawing and the install command 1`] = ` +" + + + + +hello-graph · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    +

    hello-graph

    +

    Five nodes with one fail edge, for the drawing test.

    +

    UniverLab/graphs — 7 stars

    +

    Star on GitHub

    +

    Install

    +
    canopy graph install hello-graph
    +

    Drawing

    buildtestgatetriagedocs +

    Agent nodes

    build, docs

    These agent nodes need a harness to run.

    +

    README

    +

    hello-graph

    +

    A graph the page tests render, with **one fail edge**.

    +

    Install

    +
    canopy graph install hello-graph
    +

    Read the docs and run npm test.

    +
    + + + + +" +`; + +exports[`route snapshots > renders /llms.txt with one line per document 1`] = ` +"# graphs.univerlab.org — the Canopy graph and node catalogue. + +hello-graph Five nodes with one fail edge, for the drawing test. hello-graph +hello-node Example catalogue node: drafts an implementation plan. hello-node +" +`; + +exports[`route snapshots > renders /n/ without a drawing 1`] = ` +" + + + + +hello-node · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    +

    hello-node

    +

    Example catalogue node: drafts an implementation plan.

    +

    UniverLab/graphs — 7 stars

    +

    Star on GitHub

    +

    Install

    +
    canopy node install hello-node
    + + +

    README

    +
    +
    + + + + +" +`; + +exports[`route snapshots > renders /search with community results 1`] = ` +" + + + + +Results for “flow” · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    + + +

    Results for “flow”

    +
    • hello-graph — 2 starsMinimal example graph: implement, check, gate, and a router with two routes.
    +
    + + + + +" +`; + +exports[`the SVG drawing > draws five labelled boxes, one dashed fail edge, kinds as classes 1`] = ` +" + + + + +hello-graph · graphs.univerlab.org + + + + + + + + + +

    A catalogue of Canopy graphs and nodes.

    +
    +

    hello-graph

    +

    Five nodes with one fail edge, for the drawing test.

    +

    UniverLab/graphs — 7 stars

    +

    Star on GitHub

    +

    Install

    +
    canopy graph install hello-graph
    +

    Drawing

    buildtestgatetriagedocs +

    Agent nodes

    build, docs

    These agent nodes need a harness to run.

    +

    README

    +

    hello-graph

    +

    A graph the page tests render, with **one fail edge**.

    +

    Install

    +
    canopy graph install hello-graph
    +

    Read the docs and run npm test.

    +
    + + + + +" +`; diff --git a/worker/test/pages-fixture.ts b/worker/test/pages-fixture.ts new file mode 100644 index 0000000..ca943e9 --- /dev/null +++ b/worker/test/pages-fixture.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Page fixtures: a five-node graph with exactly one fail edge (the SVG + * drawing test's subject) and the READMEs the sanitizer and the markdown + * twin are checked against — one well-formed markdown README, one carrying + * every flavour of raw HTML the renderer must never let through. + */ + +/** One node of the five-node fixture. */ +function node(name: string, kind: string, position: number): Record { + return { name, kind, position, config: {} }; +} + +/** The five-node document: build → test → gate → triage → docs, one fail edge back. */ +export const FIVE_NODE_GRAPH: Record = { + format_version: 6, + name: "hello-graph", + description: "Five nodes with one fail edge, for the drawing test.", + nodes: [ + node("build", "agent", 1), + node("test", "check", 2), + node("gate", "gate", 3), + node("triage", "router", 4), + node("docs", "agent", 5), + ], + edges: [ + { from_node: "build", to_node: "test", condition: "pass" }, + { from_node: "test", to_node: "gate", condition: "always" }, + { from_node: "test", to_node: "build", condition: "fail" }, + { from_node: "gate", to_node: "triage", condition: "pass" }, + { from_node: "triage", to_node: "docs", condition: { route: "ship" } }, + ], +}; + +/** The five-node document as GitHub would answer it serialized. */ +export const FIVE_NODE_GRAPH_TEXT = JSON.stringify(FIVE_NODE_GRAPH); + +/** A well-formed markdown README: heading, paragraph, fence, link, code. */ +export const PAGES_README_TEXT = [ + `# hello-graph`, + ``, + `A graph the page tests render, with **one fail edge**.`, + ``, + `## Install`, + ``, + "```sh", + `canopy graph install hello-graph`, + "```", + ``, + `Read the [docs](https://univerlab.org/canopy/) and run \`npm test\`.`, + ``, +].join("\n"); + +/** A README carrying script, img-onerror, bold, and a javascript: link. */ +export const EVIL_README_TEXT = [ + `# evil`, + ``, + ``, + ``, + ``, + ``, + `bold and [a link](https://example.com/page?a=1&b=2) plus [bad](javascript:alert(3))`, + ``, +].join("\n"); diff --git a/worker/test/pages.test.ts b/worker/test/pages.test.ts new file mode 100644 index 0000000..f7cd071 --- /dev/null +++ b/worker/test/pages.test.ts @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The catalogue pages: route snapshots against mocked data, the markdown + * twin, the sanitizer, the SVG drawing, the calm 503 and the page shell. + * Everything runs on the injected fakes (fakes.ts), never a global. + */ +import { describe, expect, it } from "vitest"; +import { NOT_PAGE, handlePage } from "../src/pages/routes.js"; +import { BUSY_BODY, COPY_BUTTON, FOOTER_HREF, FOOTER_TEXT, SEARCH_BUTTON, SEARCH_LABEL } from "../src/pages/copy.js"; +import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; +import { + EVIL_README_TEXT, + FIVE_NODE_GRAPH_TEXT, + PAGES_README_TEXT, +} from "./pages-fixture.js"; +import { VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; + +const ORIGIN = "https://graphs.univerlab.org"; +const REPO_DIR = "repos/UniverLab/graphs"; +const SEARCH = "search/repositories"; +const HTML = { "content-type": "text/html" }; + +/** Everything the official listing needs: one five-node graph, one node. */ +function officialStubs(): Stub[] { + return [ + { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: "hello-graph", type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: dirBody([{ name: "hello-node", type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", FIVE_NODE_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", PAGES_README_TEXT) }, + { url: `${REPO_DIR}/contents/registry/nodes/hello-node/node.json`, status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, + { url: `${REPO_DIR}/contents/registry/nodes/hello-node/README.md`, status: 404, body: {} }, + { url: REPO_DIR, status: 200, body: { stargazers_count: 7 } }, + ]; +} + +/** One community repository holding a graph called flow. */ +function communityStubs(): Stub[] { + return [ + { url: "repos/acme/things/contents/graphs/flow/graph.json", status: 200, body: fileBody("graphs/flow/graph.json", VALID_GRAPH_TEXT) }, + { url: "repos/acme/things/contents/graphs/flow/README.md", status: 404, body: {} }, + ]; +} + +/** The search answer the /search tests read: one repository holding flow. */ +function searchStubs(): Stub[] { + return [ + ...officialStubs(), + ...communityStubs(), + { + url: SEARCH, + status: 200, + body: { + total_count: 1, + items: [{ name: "things", owner: { login: "acme" }, default_branch: "main", stargazers_count: 2 }], + }, + }, + { url: "repos/acme/things/git/trees/main", status: 200, body: { tree: [{ path: "graphs/flow/graph.json", type: "blob" }] } }, + ]; +} + +interface Options { + stubs?: Stub[]; + accept?: string; + env?: Record; + method?: string; +} + +async function call(path: string, options: Options = {}): Promise { + const fake = fakeFetch(options.stubs ?? officialStubs()); + const headers = new Headers(); + if (options.accept !== undefined) { + headers.set("accept", options.accept); + } + const req = new Request(`${ORIGIN}${path}`, { method: options.method ?? "GET", headers }); + const answer = await handlePage(req, options.env ?? {}, { + fetchImpl: fake.fetchImpl, + cache: new FakeCache(), + }); + expect(answer).not.toBe(NOT_PAGE); + return answer as unknown as Response; +} + +describe("route snapshots", () => { + it("renders / with the official listing first", async () => { + const html = await (await call("/")).text(); + expect(html).toMatchSnapshot(); + expect(html).toContain(``); + expect(html).toContain(``); + }); + + it("renders /search with community results", async () => { + expect(await (await call("/search?q=flow&sort=top", { stubs: searchStubs() })).text()).toMatchSnapshot(); + }); + + it("renders /g/ with the drawing and the install command", async () => { + expect(await (await call("/g/hello-graph")).text()).toMatchSnapshot(); + }); + + it("renders /g/// with the community ref", async () => { + const response = await call("/g/acme/things/flow", { stubs: [...officialStubs(), ...communityStubs()] }); + expect(await response.text()).toMatchSnapshot(); + }); + + it("renders /n/ without a drawing", async () => { + expect(await (await call("/n/hello-node")).text()).toMatchSnapshot(); + }); + + it("renders /llms.txt with one line per document", async () => { + expect(await (await call("/llms.txt")).text()).toMatchSnapshot(); + }); +}); + +describe("the markdown twin", () => { + it("answers Accept: text/markdown with the twin, README verbatim, no HTML", async () => { + const response = await call("/g/hello-graph", { accept: "text/markdown" }); + expect(response.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + const twin = await response.text(); + expect(twin).toContain(PAGES_README_TEXT); + expect(twin).toContain("canopy graph install hello-graph"); + expect(twin).not.toContain("<"); + }); + + it("serves the twin for the listing routes too", async () => { + for (const path of ["/", "/search?q=flow", "/n/hello-node"]) { + const response = await call(path, { stubs: searchStubs(), accept: "text/markdown" }); + expect(response.headers.get("content-type"), path).toBe("text/markdown; charset=utf-8"); + expect(await response.text(), path).not.toContain("<"); + } + }); +}); + +describe("the sanitizer", () => { + it("lets no raw HTML through and marks links nofollow", async () => { + const evil = officialStubs().map((stub) => + stub.url.endsWith("README.md") ? { ...stub, body: fileBody("graphs/hello-graph/README.md", EVIL_README_TEXT) } : stub, + ); + const html = await (await call("/g/hello-graph", { stubs: evil })).text(); + // The tags survive only as escaped text, never as markup. + expect(html).toContain("<script>alert(1)</script>"); + expect(html).toContain("<img src=x onerror="); + expect(html).not.toContain(""); + expect(html).not.toContain('onerror="alert'); + expect(html).not.toContain('href="javascript:'); + expect(html).toContain(``); + }); +}); + +describe("the SVG drawing", () => { + it("draws five labelled boxes, one dashed fail edge, kinds as classes", async () => { + const html = await (await call("/g/hello-graph")).text(); + expect(html).toMatchSnapshot(); + for (const label of ["build", "test", "gate", "triage", "docs"]) { + expect(html).toContain(`>${label}
    `); + } + expect((html.match(/class="edge fail"/g) ?? [])).toHaveLength(1); + expect((html.match(/class="edge pass"/g) ?? [])).toHaveLength(2); + expect((html.match(/class="edge other"/g) ?? [])).toHaveLength(2); + for (const kind of ["kind-agent", "kind-check", "kind-gate", "kind-router"]) { + expect(html).toContain(`class="node ${kind}"`); + } + expect(html).toContain(`stroke-dasharray`); + }); +}); + +describe("data-layer failures", () => { + it("answers a typed GitHub error with a calm 503, never a stack trace", async () => { + const response = await call("/g/hello-graph", { + stubs: [{ url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 500, body: {} }], + }); + expect(response.status).toBe(503); + const html = await response.text(); + expect(html).toContain(BUSY_BODY); + expect(html).not.toContain("Error:"); + expect(html).not.toContain("at Object"); + expect(html).not.toContain("stack"); + }); + + it("answers a rate-limited listing with the same calm 503", async () => { + const response = await call("/", { + stubs: [{ + url: `${REPO_DIR}/contents/registry/graphs`, + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "2000000000" }, + }], + }); + expect(response.status).toBe(503); + expect(await response.text()).toContain(BUSY_BODY); + }); + + it("answers an unreadable upstream body with the calm page, never a crash", async () => { + const response = await call("/g/hello-graph", { + stubs: [{ url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, text: "portal", headers: HTML }], + }); + // The data layer degrades an unreadable body into a missing document. + expect(response.status).toBe(404); + expect(await response.text()).toContain("Not found"); + }); + + it("answers a missing document with a calm 404", async () => { + const response = await call("/g/nope", { stubs: [{ url: `${REPO_DIR}/contents/registry/graphs/nope`, status: 404, body: {} }] }); + expect(response.status).toBe(404); + expect(await response.text()).toContain("Not found"); + }); +}); + +describe("the page shell", () => { + it("carries the fonts, both theme pairs, the hour script, the footer and a keyboard copy button", async () => { + const html = await (await call("/g/hello-graph")).text(); + // The visual system of univerlab.org. + expect(html).toContain("fonts.googleapis.com/css2"); + expect(html).toContain("Old+Standard+TT"); + expect(html).toContain("EB+Garamond"); + expect(html).toContain("Sometype+Mono"); + for (const hex of ["#f4f0ea", "#1d1510", "#222928", "#ecebe4", "#3fb8b0"]) { + expect(html).toContain(hex); + } + expect(html).toContain("localStorage"); + expect(html).toContain("getHours"); + expect(html).toContain(`${FOOTER_TEXT}`); + // Accessibility and weight: no framework, real buttons, reduced motion. + expect(html).toContain("prefers-reduced-motion"); + expect(html).toMatch(/ - +

    Official

    Graphs

    • hello-graph — 7 starsFive nodes with one fail edge, for the drawing test.
    diff --git a/worker/test/pages.test.ts b/worker/test/pages.test.ts index f7cd071..9bd2413 100644 --- a/worker/test/pages.test.ts +++ b/worker/test/pages.test.ts @@ -86,6 +86,8 @@ describe("route snapshots", () => { expect(html).toMatchSnapshot(); expect(html).toContain(``); expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain(``); }); it("renders /search with community results", async () => { @@ -288,4 +290,22 @@ describe("routing", () => { expect(response.status).toBe(404); expect(await response.text()).toContain("Not found"); }); + + it("keeps a raw directory-name slug inert in listing links", async () => { + const slug = 'evil" onmouseover="alert(1)'; + const encoded = "evil%22%20onmouseover%3D%22alert(1)"; + const stubs = [ + { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: slug, type: "dir" }]) }, + { url: `${REPO_DIR}/contents/registry/graphs/evil%22%20onmouseover=%22alert(1)/graph.json`, status: 200, body: fileBody("graphs/x/graph.json", VALID_GRAPH_TEXT) }, + { url: `${REPO_DIR}/contents/registry/graphs/${encoded}/README.md`, status: 404, body: {} }, + { url: REPO_DIR, status: 200, body: { stargazers_count: 1 } }, + ]; + const html = await (await call("/", { stubs })).text(); + expect(html).toContain(`href="/g/${encoded}"`); + expect(html).not.toContain(`onmouseover="alert(1)"`); + const twin = await (await call("/", { stubs, accept: "text/markdown" })).text(); + expect(twin).toContain(`](/g/${encoded})`); + // No link target in the twin may carry a raw quote. + expect(twin).not.toMatch(/\]\(\/g\/[^)\n]*"/i); + }); }); From 382da71e1f94d9744f15a2bf1a3ce1e50e36bb60 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 19:31:55 -0500 Subject: [PATCH 010/180] feat(registry): publish the first official graphs and nodes --- registry/graphs/quality-bubble/README.md | 30 ++++ registry/graphs/quality-bubble/graph.json | 80 ++++++++++ registry/graphs/single-committer/README.md | 44 ++++++ registry/graphs/single-committer/graph.json | 154 ++++++++++++++++++++ registry/graphs/spec-loop/README.md | 41 ++++++ registry/graphs/spec-loop/graph.json | 139 ++++++++++++++++++ registry/nodes/cargo-gates/README.md | 17 +++ registry/nodes/cargo-gates/node.json | 9 ++ registry/nodes/commit-check/README.md | 25 ++++ registry/nodes/commit-check/node.json | 9 ++ registry/nodes/ensemble-proposers/README.md | 19 +++ registry/nodes/ensemble-proposers/node.json | 14 ++ registry/nodes/implementer/README.md | 17 +++ registry/nodes/implementer/node.json | 13 ++ registry/nodes/resilience/README.md | 18 +++ registry/nodes/resilience/node.json | 13 ++ registry/nodes/reviewer-committer/README.md | 18 +++ registry/nodes/reviewer-committer/node.json | 13 ++ 18 files changed, 673 insertions(+) create mode 100644 registry/graphs/quality-bubble/README.md create mode 100644 registry/graphs/quality-bubble/graph.json create mode 100644 registry/graphs/single-committer/README.md create mode 100644 registry/graphs/single-committer/graph.json create mode 100644 registry/graphs/spec-loop/README.md create mode 100644 registry/graphs/spec-loop/graph.json create mode 100644 registry/nodes/cargo-gates/README.md create mode 100644 registry/nodes/cargo-gates/node.json create mode 100644 registry/nodes/commit-check/README.md create mode 100644 registry/nodes/commit-check/node.json create mode 100644 registry/nodes/ensemble-proposers/README.md create mode 100644 registry/nodes/ensemble-proposers/node.json create mode 100644 registry/nodes/implementer/README.md create mode 100644 registry/nodes/implementer/node.json create mode 100644 registry/nodes/resilience/README.md create mode 100644 registry/nodes/resilience/node.json create mode 100644 registry/nodes/reviewer-committer/README.md create mode 100644 registry/nodes/reviewer-committer/node.json diff --git a/registry/graphs/quality-bubble/README.md b/registry/graphs/quality-bubble/README.md new file mode 100644 index 0000000..1273756 --- /dev/null +++ b/registry/graphs/quality-bubble/README.md @@ -0,0 +1,30 @@ +# quality-bubble + +What this graph is for: covering the change already in the worktree +with honest tests, with no spec to write. The bubble tests the current +uncommitted change by design, so it has no lock and no clean-tree +start check — run it with the change already present but uncommitted. +Every failure routes back to the implementer for another attempt. + +Nodes in order: + +1. `implementer` (agent) — writes a small, focused set of tests for the uncommitted change; never commits. +2. `test-run` (check) — runs the project's tests (installer placeholder). +3. `test-size-cap` (check) — rejects a bloated diff: added plus removed lines must stay within the cap. +4. `anti-cheat-reviewer` (agent, commit rights) — rejects cheating tests (copied expectations, tests that cannot fail, disabled tests, over-mocking) and lands exactly one commit when the tests honestly cover the change. + +Fail edges: `test-run`, `test-size-cap`, and `anti-cheat-reviewer` +all route `fail` back to `implementer`. + +What the installer must fill in: + +- Harness: every agent node carries no `platform`/`model` — bind one per agent node (`implementer`, `anti-cheat-reviewer`) at install time. +- Gate command: `test-run` is a failing placeholder. Replace it with the project's test command. +- Size cap: `test-size-cap` caps the diff at 600 added-plus-removed lines. Adjust the cap to the project's test budget (marked `EDIT ME` in the command). +- Workdir: point the installed graph at the repository it should run in. + +Install: + +```sh +canopy graph install quality-bubble +``` diff --git a/registry/graphs/quality-bubble/graph.json b/registry/graphs/quality-bubble/graph.json new file mode 100644 index 0000000..4f9f6dd --- /dev/null +++ b/registry/graphs/quality-bubble/graph.json @@ -0,0 +1,80 @@ +{ + "format_version": 6, + "name": "quality-bubble", + "description": "A spec-less bubble that covers the current uncommitted change with honest tests: write tests, run them, cap their size, and review them for cheating.", + "nodes": [ + { + "name": "implementer", + "kind": "agent", + "position": 1, + "config": { + "prompt_preset": "test-implementer" + } + }, + { + "name": "test-run", + "kind": "check", + "position": 2, + "config": { + "command": "# INSTALLER: replace this placeholder with your project's test command\n# (e.g. npm test). The placeholder fails until you replace it.\nexit 1" + } + }, + { + "name": "test-size-cap", + "kind": "check", + "position": 3, + "config": { + "command": "# EDIT ME: adjust the 600-line cap to your project's test budget.\ngit add -N . && test \"$(git diff --numstat | awk '{a+=$1; d+=$2} END {print a+d+0}')\" -le 600" + } + }, + { + "name": "anti-cheat-reviewer", + "kind": "agent", + "position": 4, + "config": { + "prompt_preset": "anti-cheat-reviewer", + "commit_rights": true + } + } + ], + "edges": [ + { + "from_node": "implementer", + "to_node": "test-run", + "condition": "pass" + }, + { + "from_node": "test-run", + "to_node": "test-size-cap", + "condition": "pass" + }, + { + "from_node": "test-run", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "test-size-cap", + "to_node": "anti-cheat-reviewer", + "condition": "pass" + }, + { + "from_node": "test-size-cap", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "anti-cheat-reviewer", + "to_node": "implementer", + "condition": "fail" + } + ], + "presets": { + "test-implementer": { + "content": "# [ROLE]: Who You Are\n\nYou are a careful test author working in this repository. An uncommitted change is already in the worktree; your job is to cover it with a focused set of tests.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n FIRST, read the current uncommitted change (status and diff) and the code it touches. Test the change as it stands — do not redesign or reimplement it.\n \n\n \n Write a small, focused set of tests that proves the change behaves as intended, including its edge cases and error paths. Keep the suite tight: a size cap downstream rejects a bloated diff.\n \n\n \n Previous feedback (if any): {{previous_feedback}}\n If it reads \"(none)\", this is a fresh attempt. Otherwise, address every point of the feedback as part of your changes.\n \n\n \n Before reporting, run the project's test command locally and confirm your new tests pass — never assume they pass without running them.\n \n\n \n If you are genuinely blocked — missing credentials, an unresolvable conflict, a decision only a human can make — make the final line of your report start with \"BLOCKER:\" followed by a one-paragraph explanation of exactly why.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading any file in the repository, adding test files, and running the project's checks to verify your work.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing your changes under any circumstances — a separate reviewer step reviews your diff and commits it.\n \n\n \n Changing non-test source files except for the smallest testability fix the tests strictly require — and never the behavior under test.\n \n\n\n\n" + }, + "anti-cheat-reviewer": { + "content": "# [ROLE]: Who You Are\n\nYou are the anti-cheat reviewer and committer for this graph. You judge whether the new tests honestly prove the change works — then you land exactly one commit.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Review the current diff: the tests must honestly cover the uncommitted change they accompany. A test that cannot fail proves nothing.\n \n\n \n Reject cheating in any form: expected values copied from buggy output, assertions that never execute, disabled or skipped tests, mocks standing in for the code under test, and coverage of trivial lines while the risky paths go untested.\n \n\n \n Previous feedback (if any): {{previous_feedback}}\n Weigh it alongside your own reading of the diff.\n \n\n \n If the tests are honest and cover the change, make EXACTLY ONE commit covering ONLY this work. Use a concise, descriptive commit message with NO trailers of any kind (no Co-Authored-By, no issue references, no generated-by footers).\n \n\n \n If the worktree has no changes at all, FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n \n If the tests cheat, or leave the change meaningfully uncovered, FAIL with a specific, actionable list of what's wrong so the test author can address it directly — vague feedback is not acceptable.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff and running the test suite, and making exactly one commit when the tests honestly cover the change.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Pushing, under any circumstances.\n \n\n \n Committing an empty diff.\n \n\n \n Editing the tests yourself to make them pass — fail them back to the test author instead.\n \n\n\n\n" + } + } +} diff --git a/registry/graphs/single-committer/README.md b/registry/graphs/single-committer/README.md new file mode 100644 index 0000000..94e92b3 --- /dev/null +++ b/registry/graphs/single-committer/README.md @@ -0,0 +1,44 @@ +# single-committer + +What this graph is for: the spec loop with a harder review bar. Two +reviewers — one constructive, one adversarial — must both pass before +a single committer makes the final check and lands exactly one +commit. Only the committer commits. Every failure routes back to the +implementer for another attempt. + +Nodes in order: + +1. `start-check` (check) — refuses a dirty worktree before anything runs. +2. `lock-repo` (check) — takes the repo lock for this spec. +3. `implementer` (agent) — implements exactly the spec; never commits. +4. `full-gates` (check) — the deterministic gates (installer placeholder). +5. `reviewers` (parallel ensemble, no commit rights) — member 1 reviews constructively, member 2 reviews adversarially; both must pass. +6. `committer` (agent, commit rights) — final check against the spec plus the reviewers' verdict, then exactly one commit. +7. `commit-check` (check) — proves a commit landed since the spec started. +8. `unlock` (check) — releases the repo lock. +9. `human-verdict` (check) — waits up to 24h for a human verdict file; anything but approval routes back to the implementer. + +Fail edges: `full-gates`, the `reviewers` quorum, `committer`, +`commit-check`, and `human-verdict` all route `fail` back to +`implementer`. + +What the installer must fill in: + +- Harness: every agent node and ensemble member carries no `platform`/`model` — bind one per agent node (`implementer`, `committer`) and per reviewer member at install time. +- Gate command: `full-gates` is a failing placeholder. Replace it with the project's deterministic gates (formatter, linter, tests). +- Workdir: point the installed graph at the repository it should run in. +- Verdict file: a human approves by writing a line containing `VERDICT: APPROVED` to `.canopy/verdict` in the workdir; any other content (e.g. `VERDICT: REJECTED` plus notes) sends the spec back to the implementer. + +The commit check uses the `{{spec_start_head}}` form. For the stronger +"this run's own committer landed a commit" check — immune to a +concurrent commit from outside the run — switch it to: + +```sh +test -n "{{spec_committed_head}}" && test "$(git rev-parse HEAD)" = "{{spec_committed_head}}" +``` + +Install: + +```sh +canopy graph install single-committer +``` diff --git a/registry/graphs/single-committer/graph.json b/registry/graphs/single-committer/graph.json new file mode 100644 index 0000000..4c661ac --- /dev/null +++ b/registry/graphs/single-committer/graph.json @@ -0,0 +1,154 @@ +{ + "format_version": 6, + "name": "single-committer", + "description": "The spec loop with an adversarial second reviewer: two reviewers must both pass before a single committer lands exactly one commit.", + "nodes": [ + { + "name": "start-check", + "kind": "check", + "position": 1, + "config": { + "command": "test -z \"$(git status --porcelain)\"" + } + }, + { + "name": "lock-repo", + "kind": "check", + "position": 2, + "config": { + "command": "gitkit lock --timeout 8h --reason {{spec_name}}" + } + }, + { + "name": "implementer", + "kind": "agent", + "position": 3, + "config": { + "prompt_preset": "implementer" + } + }, + { + "name": "full-gates", + "kind": "check", + "position": 4, + "config": { + "command": "# INSTALLER: replace this placeholder with your project's deterministic gates\n# (formatter, linter, tests — e.g. npm run lint && npm run typecheck && npm test).\n# The placeholder fails until you replace it.\nexit 1" + } + }, + { + "name": "committer", + "kind": "agent", + "position": 5, + "config": { + "prompt_preset": "committer", + "commit_rights": true + } + }, + { + "name": "commit-check", + "kind": "check", + "position": 6, + "config": { + "command": "test \"$(git rev-parse HEAD)\" != \"{{spec_start_head}}\"" + } + }, + { + "name": "unlock", + "kind": "check", + "position": 7, + "config": { + "command": "gitkit unlock" + } + }, + { + "name": "human-verdict", + "kind": "check", + "position": 8, + "config": { + "command": "for i in $(seq 1 2880); do\n if [ -f .canopy/verdict ]; then cat .canopy/verdict; exit 0; fi\n sleep 30\ndone\necho \"VERDICT: TIMEOUT - no human verdict within 24h\"; exit 1", + "success_condition": "exit_code_0_and_output_contains:VERDICT: APPROVED", + "timeout_seconds": 86400 + } + } + ], + "edges": [ + { + "from_node": "start-check", + "to_node": "lock-repo", + "condition": "pass" + }, + { + "from_node": "lock-repo", + "to_node": "implementer", + "condition": "pass" + }, + { + "from_node": "implementer", + "to_node": "full-gates", + "condition": "pass" + }, + { + "from_node": "full-gates", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "committer", + "to_node": "commit-check", + "condition": "pass" + }, + { + "from_node": "committer", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "commit-check", + "to_node": "unlock", + "condition": "pass" + }, + { + "from_node": "commit-check", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "unlock", + "to_node": "human-verdict", + "condition": "pass" + }, + { + "from_node": "human-verdict", + "to_node": "implementer", + "condition": "fail" + } + ], + "ensembles": [ + { + "name": "reviewers", + "kind": "parallel", + "commit_rights": false, + "prompt_template": "# [ROLE]: Who You Are\n\nYou are a careful code reviewer for this graph. You review only — a separate committer lands the work.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Review the current diff strictly against this spec — nothing else:\n\n {{spec_content}}\n \n\n \n If the diff correctly and completely implements the spec, report PASS with a short confirmation of what you checked.\n \n\n \n If the diff is wrong, incomplete, or diverges from the spec, report FAIL with a specific, actionable list of what's wrong so the implementer can address it directly — vague feedback is not acceptable.\n \n\n \n If the worktree has no changes at all, report FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff, the spec, and any file in the repository needed to judge correctness.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing anything under any circumstances — you have no commit rights, and a commit from you fails this review deterministically.\n \n\n \n Editing any file — review the diff as it stands.\n \n\n\n\n", + "entry_from_node": "full-gates", + "entry_condition": "pass", + "on_pass_to": "committer", + "on_fail_to": "implementer", + "min_pass": 2, + "timeout_minutes": 30, + "members": [ + {}, + { + "prompt_override": "# [ROLE]: Who You Are\n\nYou are the adversarial reviewer for this graph. Your job is to break the diff: assume it is wrong until every line earns your trust. You review only — you never commit.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Attack the current diff against this spec — nothing else:\n\n {{spec_content}}\n \n\n \n Hunt for what a friendly review misses: spec lines with no corresponding change, edge cases the code ignores, error paths that are untested or silently swallowed, tests that cannot fail, and changes the spec never asked for.\n \n\n \n If you find any defect, however small, report FAIL with a specific, actionable list of every flaw so the implementer can address each one directly — vague feedback is not acceptable.\n \n\n \n Report PASS only when the diff is correct, complete, and minimal against the spec, and say briefly what you tried to break.\n \n\n \n If the worktree has no changes at all, report FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff, the spec, and any file in the repository needed to judge correctness.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing anything under any circumstances — you have no commit rights, and a commit from you fails this review deterministically.\n \n\n \n Editing any file — review the diff as it stands.\n \n\n \n Passing a diff with a known flaw because the flaw looks small — small flaws are still FAILs.\n \n\n\n\n" + } + ] + } + ], + "presets": { + "implementer": { + "content": "# [ROLE]: Who You Are\n\nYou are a careful Rust implementer working in this repository.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n FIRST, verify the premise of the spec against the actual code: read the relevant files before changing anything. If the premise is wrong — it describes something that isn't true of the current code, or asks for a change that doesn't actually apply — stop and report exactly why instead of forcing a change to fit a wrong premise.\n \n\n \n Otherwise, implement exactly this spec, nothing more and nothing less:\n\n {{spec_content}}\n \n\n \n Previous feedback (if any): {{previous_feedback}}\n If it reads \"(none)\", this is a fresh implementation. Otherwise, address every point of the feedback as part of your changes.\n \n\n \n Before reporting, run the checks relevant to what you changed (formatter, linter, tests) locally — never assume they pass without running them.\n \n\n \n If you are genuinely blocked — missing credentials, an unresolvable conflict, a decision only a human can make — make the final line of your report start with \"BLOCKER:\" followed by a one-paragraph explanation of exactly why.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading any file in the repository, and running local checks (formatter, linter, tests) to verify your work.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing your changes under any circumstances — a separate reviewer step reviews your diff and commits it.\n \n\n\n\n" + }, + "committer": { + "content": "# [ROLE]: Who You Are\n\nYou are the committer for this graph. Two reviewers have already passed the diff; you make the final check and land exactly one commit.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Confirm the current diff satisfies this spec — nothing else:\n\n {{spec_content}}\n \n\n \n Read the reviewers' consolidated verdict below and weigh it alongside your own check:\n\n {{previous_feedback}}\n \n\n \n If the diff correctly and completely implements the spec, make EXACTLY ONE commit covering ONLY this spec's work. Use a concise, descriptive commit message with NO trailers of any kind (no Co-Authored-By, no issue references, no generated-by footers).\n \n\n \n If the worktree has no changes at all, FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n \n If the diff is wrong, incomplete, or diverges from the spec, FAIL with a specific, actionable list of what's wrong so the implementer can address it directly — vague feedback is not acceptable.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff, this spec, and the reviewers' verdict, and making exactly one commit when the spec is fully satisfied.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Pushing, under any circumstances.\n \n\n \n Committing an empty diff.\n \n\n \n Editing files to fix the diff yourself — fail it back to the implementer instead.\n \n\n\n\n" + } + } +} diff --git a/registry/graphs/spec-loop/README.md b/registry/graphs/spec-loop/README.md new file mode 100644 index 0000000..512f6ea --- /dev/null +++ b/registry/graphs/spec-loop/README.md @@ -0,0 +1,41 @@ +# spec-loop + +What this graph is for: running one spec through the lab's proven +loop — implement, deterministic gates, review-and-commit, proof the +commit landed, release the repo lock, and a human verdict. Every +failure routes back to the implementer for another attempt. + +Nodes in order: + +1. `start-check` (check) — refuses a dirty worktree before anything runs. +2. `lock-repo` (check) — takes the repo lock for this spec. +3. `implementer` (agent) — implements exactly the spec; never commits. +4. `full-gates` (check) — the deterministic gates (installer placeholder). +5. `reviewer` (agent, commit rights) — reviews the diff against the spec and lands exactly one commit. +6. `commit-check` (check) — proves a commit landed since the spec started. +7. `unlock` (check) — releases the repo lock. +8. `human-verdict` (check) — waits up to 24h for a human verdict file; anything but approval routes back to the implementer. + +Fail edges: `full-gates`, `reviewer`, `commit-check`, and +`human-verdict` all route `fail` back to `implementer`. + +What the installer must fill in: + +- Harness: every agent node carries no `platform`/`model` — bind one per agent node (`implementer`, `reviewer`) at install time. +- Gate command: `full-gates` is a failing placeholder. Replace it with the project's deterministic gates (formatter, linter, tests). +- Workdir: point the installed graph at the repository it should run in. +- Verdict file: a human approves by writing a line containing `VERDICT: APPROVED` to `.canopy/verdict` in the workdir; any other content (e.g. `VERDICT: REJECTED` plus notes) sends the spec back to the implementer. + +The commit check uses the `{{spec_start_head}}` form. For the stronger +"this run's own committer landed a commit" check — immune to a +concurrent commit from outside the run — switch it to: + +```sh +test -n "{{spec_committed_head}}" && test "$(git rev-parse HEAD)" = "{{spec_committed_head}}" +``` + +Install: + +```sh +canopy graph install spec-loop +``` diff --git a/registry/graphs/spec-loop/graph.json b/registry/graphs/spec-loop/graph.json new file mode 100644 index 0000000..ed40551 --- /dev/null +++ b/registry/graphs/spec-loop/graph.json @@ -0,0 +1,139 @@ +{ + "format_version": 6, + "name": "spec-loop", + "description": "The lab's proven spec loop: implement, gate, review-and-commit, verify the commit, and collect a human verdict, with every failure routed back to the implementer.", + "nodes": [ + { + "name": "start-check", + "kind": "check", + "position": 1, + "config": { + "command": "test -z \"$(git status --porcelain)\"" + } + }, + { + "name": "lock-repo", + "kind": "check", + "position": 2, + "config": { + "command": "gitkit lock --timeout 8h --reason {{spec_name}}" + } + }, + { + "name": "implementer", + "kind": "agent", + "position": 3, + "config": { + "prompt_preset": "implementer" + } + }, + { + "name": "full-gates", + "kind": "check", + "position": 4, + "config": { + "command": "# INSTALLER: replace this placeholder with your project's deterministic gates\n# (formatter, linter, tests — e.g. npm run lint && npm run typecheck && npm test).\n# The placeholder fails until you replace it.\nexit 1" + } + }, + { + "name": "reviewer", + "kind": "agent", + "position": 5, + "config": { + "prompt_preset": "reviewer", + "commit_rights": true + } + }, + { + "name": "commit-check", + "kind": "check", + "position": 6, + "config": { + "command": "test \"$(git rev-parse HEAD)\" != \"{{spec_start_head}}\"" + } + }, + { + "name": "unlock", + "kind": "check", + "position": 7, + "config": { + "command": "gitkit unlock" + } + }, + { + "name": "human-verdict", + "kind": "check", + "position": 8, + "config": { + "command": "for i in $(seq 1 2880); do\n if [ -f .canopy/verdict ]; then cat .canopy/verdict; exit 0; fi\n sleep 30\ndone\necho \"VERDICT: TIMEOUT - no human verdict within 24h\"; exit 1", + "success_condition": "exit_code_0_and_output_contains:VERDICT: APPROVED", + "timeout_seconds": 86400 + } + } + ], + "edges": [ + { + "from_node": "start-check", + "to_node": "lock-repo", + "condition": "pass" + }, + { + "from_node": "lock-repo", + "to_node": "implementer", + "condition": "pass" + }, + { + "from_node": "implementer", + "to_node": "full-gates", + "condition": "pass" + }, + { + "from_node": "full-gates", + "to_node": "reviewer", + "condition": "pass" + }, + { + "from_node": "full-gates", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "reviewer", + "to_node": "commit-check", + "condition": "pass" + }, + { + "from_node": "reviewer", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "commit-check", + "to_node": "unlock", + "condition": "pass" + }, + { + "from_node": "commit-check", + "to_node": "implementer", + "condition": "fail" + }, + { + "from_node": "unlock", + "to_node": "human-verdict", + "condition": "pass" + }, + { + "from_node": "human-verdict", + "to_node": "implementer", + "condition": "fail" + } + ], + "presets": { + "implementer": { + "content": "# [ROLE]: Who You Are\n\nYou are a careful Rust implementer working in this repository.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n FIRST, verify the premise of the spec against the actual code: read the relevant files before changing anything. If the premise is wrong — it describes something that isn't true of the current code, or asks for a change that doesn't actually apply — stop and report exactly why instead of forcing a change to fit a wrong premise.\n \n\n \n Otherwise, implement exactly this spec, nothing more and nothing less:\n\n {{spec_content}}\n \n\n \n Previous feedback (if any): {{previous_feedback}}\n If it reads \"(none)\", this is a fresh implementation. Otherwise, address every point of the feedback as part of your changes.\n \n\n \n Before reporting, run the checks relevant to what you changed (formatter, linter, tests) locally — never assume they pass without running them.\n \n\n \n If you are genuinely blocked — missing credentials, an unresolvable conflict, a decision only a human can make — make the final line of your report start with \"BLOCKER:\" followed by a one-paragraph explanation of exactly why.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading any file in the repository, and running local checks (formatter, linter, tests) to verify your work.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing your changes under any circumstances — a separate reviewer step reviews your diff and commits it.\n \n\n\n\n" + }, + "reviewer": { + "content": "# [ROLE]: Who You Are\n\nYou are the reviewer and committer for this graph.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Review the current diff strictly against this spec — nothing else:\n\n {{spec_content}}\n \n\n \n If the diff correctly and completely implements the spec, make EXACTLY ONE commit covering ONLY this spec's work. Use a concise, descriptive commit message with NO trailers of any kind (no Co-Authored-By, no issue references, no generated-by footers).\n \n\n \n If the worktree has no changes at all, FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n \n If the diff is wrong, incomplete, or diverges from the spec, FAIL with a specific, actionable list of what's wrong so the implementer can address it directly — vague feedback is not acceptable.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff and this spec, and making exactly one commit when the spec is fully satisfied.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Pushing, under any circumstances.\n \n\n \n Committing an empty diff.\n \n\n\n\n" + } + } +} diff --git a/registry/nodes/cargo-gates/README.md b/registry/nodes/cargo-gates/README.md new file mode 100644 index 0000000..f12d328 --- /dev/null +++ b/registry/nodes/cargo-gates/README.md @@ -0,0 +1,17 @@ +# cargo-gates + +What this node is for: the deterministic gate step of a Rust spec +loop. One check command runs the formatter, the linter, and the test +suite. + +Config: the `command` only. No `platform`, `model`, or `cli` +bindings. + +What the installer must fill in: nothing — unless the project is not +Rust, in which case replace the command with the project's own gates. + +Install: + +```sh +canopy node install cargo-gates +``` diff --git a/registry/nodes/cargo-gates/node.json b/registry/nodes/cargo-gates/node.json new file mode 100644 index 0000000..ace84f7 --- /dev/null +++ b/registry/nodes/cargo-gates/node.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "name": "cargo-gates", + "kind": "check", + "description": "Deterministic Rust gates: formatter, linter, and tests in one check command.", + "config": { + "command": "cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test" + } +} diff --git a/registry/nodes/commit-check/README.md b/registry/nodes/commit-check/README.md new file mode 100644 index 0000000..b40ed08 --- /dev/null +++ b/registry/nodes/commit-check/README.md @@ -0,0 +1,25 @@ +# commit-check + +What this node is for: the proof step after a committer. It verifies a +commit landed since the spec started by comparing HEAD against the +spec-start marker. + +Config: the `command` only. No `platform`, `model`, or `cli` +bindings. + +What the installer must fill in: nothing for the basic form. For the +stronger "this run's own committer landed a commit" check — immune to +a concurrent commit from outside the run — switch the command to the +committed-head form: + +```sh +test -n "{{spec_committed_head}}" && test "$(git rev-parse HEAD)" = "{{spec_committed_head}}" +``` + +That form requires a node with commit rights earlier in the graph. + +Install: + +```sh +canopy node install commit-check +``` diff --git a/registry/nodes/commit-check/node.json b/registry/nodes/commit-check/node.json new file mode 100644 index 0000000..4f6fd2e --- /dev/null +++ b/registry/nodes/commit-check/node.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "name": "commit-check", + "kind": "check", + "description": "Verifies a commit landed since the spec started by comparing HEAD against the spec-start marker.", + "config": { + "command": "test \"$(git rev-parse HEAD)\" != \"{{spec_start_head}}\"" + } +} diff --git a/registry/nodes/ensemble-proposers/README.md b/registry/nodes/ensemble-proposers/README.md new file mode 100644 index 0000000..73a7f62 --- /dev/null +++ b/registry/nodes/ensemble-proposers/README.md @@ -0,0 +1,19 @@ +# ensemble-proposers + +What this node is for: drafting solutions cheaply before spending +implementer quota. Three parallel members draft a concrete solution +for the same spec; a downstream implementer then builds from the +pre-digested drafts without ever seeing the blank spec. + +Config: the shared `prompt_template` plus three unbound members. No +`platform`, `model`, or `cli` bindings anywhere — `min_pass` is +omitted, so every member must pass. + +What the installer must fill in: the harness per member +(platform/model) at install time; nothing else. + +Install: + +```sh +canopy node install ensemble-proposers +``` diff --git a/registry/nodes/ensemble-proposers/node.json b/registry/nodes/ensemble-proposers/node.json new file mode 100644 index 0000000..61a2512 --- /dev/null +++ b/registry/nodes/ensemble-proposers/node.json @@ -0,0 +1,14 @@ +{ + "format_version": 1, + "name": "ensemble-proposers", + "kind": "ensemble", + "description": "Three parallel proposers draft concrete solutions for the same spec so a downstream implementer builds from a pre-digested draft.", + "config": { + "prompt_template": "Draft a complete solution for this spec. Be concrete and specific — an implementer downstream may build directly from your draft without ever seeing this spec itself, so leave nothing implicit:\n\n{{spec_content}}\n\nPrevious feedback (if any): {{previous_feedback}}", + "members": [ + {}, + {}, + {} + ] + } +} diff --git a/registry/nodes/implementer/README.md b/registry/nodes/implementer/README.md new file mode 100644 index 0000000..87ada75 --- /dev/null +++ b/registry/nodes/implementer/README.md @@ -0,0 +1,17 @@ +# implementer + +What this node is for: the implementer step of a spec loop. It +verifies the spec premise against the actual code, implements exactly +the spec, and reports — it never commits. + +Config: `prompt_preset: implementer`, with the preset bundled in +`node.json`. No `platform`, `model`, or `cli` bindings. + +What the installer must fill in: the harness that runs this node +(platform/model) at install time; nothing else. + +Install: + +```sh +canopy node install implementer +``` diff --git a/registry/nodes/implementer/node.json b/registry/nodes/implementer/node.json new file mode 100644 index 0000000..bc99c1d --- /dev/null +++ b/registry/nodes/implementer/node.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "name": "implementer", + "kind": "agent", + "description": "The lab's proven implementer role: verifies the spec premise against the code, implements exactly the spec, and never commits.", + "config": { + "prompt_preset": "implementer" + }, + "preset": { + "name": "implementer", + "content": "# [ROLE]: Who You Are\n\nYou are a careful Rust implementer working in this repository.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n FIRST, verify the premise of the spec against the actual code: read the relevant files before changing anything. If the premise is wrong — it describes something that isn't true of the current code, or asks for a change that doesn't actually apply — stop and report exactly why instead of forcing a change to fit a wrong premise.\n \n\n \n Otherwise, implement exactly this spec, nothing more and nothing less:\n\n {{spec_content}}\n \n\n \n Previous feedback (if any): {{previous_feedback}}\n If it reads \"(none)\", this is a fresh implementation. Otherwise, address every point of the feedback as part of your changes.\n \n\n \n Before reporting, run the checks relevant to what you changed (formatter, linter, tests) locally — never assume they pass without running them.\n \n\n \n If you are genuinely blocked — missing credentials, an unresolvable conflict, a decision only a human can make — make the final line of your report start with \"BLOCKER:\" followed by a one-paragraph explanation of exactly why.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading any file in the repository, and running local checks (formatter, linter, tests) to verify your work.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Committing your changes under any circumstances — a separate reviewer step reviews your diff and commits it.\n \n\n\n\n" + } +} diff --git a/registry/nodes/resilience/README.md b/registry/nodes/resilience/README.md new file mode 100644 index 0000000..84dd7cc --- /dev/null +++ b/registry/nodes/resilience/README.md @@ -0,0 +1,18 @@ +# resilience + +What this node is for: the on-call medic after a failure or blocker. +It diagnoses exactly one cause — quota, transient glitch, or genuine +defect — and routes to the right next step. It never fixes the work +itself. + +Config: `prompt_preset: resilience`, with the preset bundled in +`node.json`. No `platform`, `model`, or `cli` bindings. + +What the installer must fill in: the harness that runs this node +(platform/model) at install time; nothing else. + +Install: + +```sh +canopy node install resilience +``` diff --git a/registry/nodes/resilience/node.json b/registry/nodes/resilience/node.json new file mode 100644 index 0000000..8f7af3a --- /dev/null +++ b/registry/nodes/resilience/node.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "name": "resilience", + "kind": "agent", + "description": "The on-call medic role: diagnoses a failure as quota, glitch, or genuine defect and routes to the right next step.", + "config": { + "prompt_preset": "resilience" + }, + "preset": { + "name": "resilience", + "content": "# [ROLE]: Who You Are\n\nYou are the on-call medic for this graph. A node just failed or reported a blocker. Your only job is to diagnose why and route to the right next step — you do not fix the underlying work yourself.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Read the failure/blocker context below (`{{previous_feedback}}`) and pick exactly ONE diagnosis: QUOTA, GLITCH, or OTHER.\n \n\n \n QUOTA — the failure is a rate limit, quota exhaustion, or \"try again later\" from the platform/API itself. Action: call `graph_schedule_autorun` with `graph_id` set to this graph's ID and `quota_reset_message` set to the ENTIRE failure text below, copied verbatim — do NOT extract, truncate, reformat, or translate any part of it; the engine parses the raw text itself. Then report FAIL.\n \n\n \n GLITCH — the failure is a transient infrastructure hiccup (network blip, spurious timeout, a flaky check unrelated to the spec) with no sign of a real defect. Action: report GLITCH and recommend a plain retry of the same node.\n \n\n \n OTHER — the failure reflects a genuine problem with the work itself (wrong code, an unmet spec, a missing dependency, a decision only a human can make). Action: report OTHER with a precise, actionable explanation of what's wrong so a human or the next agent can address it.\n \n\n \n State your diagnosis and its one action clearly in your report; do not hedge between diagnoses.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading files, logs, git history, and process/job status.\n \n\n \n Checking scheduling/queue state (e.g. `git status`, `git log`, `ps`, reading CI/log output).\n \n\n \n Reporting your diagnosis and recommended action via graph_complete_node / graph_report_blocker.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Writing code, or editing any file or config.\n \n\n \n Building or testing the project.\n \n\n \n Committing anything.\n \n\n \n Attempting to verify or finish the spec's actual work — that is not your job here.\n \n\n\n\n" + } +} diff --git a/registry/nodes/reviewer-committer/README.md b/registry/nodes/reviewer-committer/README.md new file mode 100644 index 0000000..f78859e --- /dev/null +++ b/registry/nodes/reviewer-committer/README.md @@ -0,0 +1,18 @@ +# reviewer-committer + +What this node is for: the review-and-commit step of a spec loop. It +reviews the diff strictly against the spec and, when the spec is fully +satisfied, lands exactly one commit. Give it commit rights in the +installing graph so the engine attributes the commit to it. + +Config: `prompt_preset: reviewer`, with the preset bundled in +`node.json`. No `platform`, `model`, or `cli` bindings. + +What the installer must fill in: the harness that runs this node +(platform/model), plus commit rights, at install time; nothing else. + +Install: + +```sh +canopy node install reviewer-committer +``` diff --git a/registry/nodes/reviewer-committer/node.json b/registry/nodes/reviewer-committer/node.json new file mode 100644 index 0000000..b121002 --- /dev/null +++ b/registry/nodes/reviewer-committer/node.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "name": "reviewer-committer", + "kind": "agent", + "description": "The lab's proven reviewer role: reviews the diff strictly against the spec and lands exactly one commit when it passes.", + "config": { + "prompt_preset": "reviewer" + }, + "preset": { + "name": "reviewer", + "content": "# [ROLE]: Who You Are\n\nYou are the reviewer and committer for this graph.\n\n\n# [INSTRUCTIONS]: Execution Logic\n\n \n Review the current diff strictly against this spec — nothing else:\n\n {{spec_content}}\n \n\n \n If the diff correctly and completely implements the spec, make EXACTLY ONE commit covering ONLY this spec's work. Use a concise, descriptive commit message with NO trailers of any kind (no Co-Authored-By, no issue references, no generated-by footers).\n \n\n \n If the worktree has no changes at all, FAIL and say so explicitly — do not treat \"nothing changed\" as success.\n \n\n \n If the diff is wrong, incomplete, or diverges from the spec, FAIL with a specific, actionable list of what's wrong so the implementer can address it directly — vague feedback is not acceptable.\n \n\n\n\n# [ALLOWED]: What You May Use\n\n \n Reading the current diff and this spec, and making exactly one commit when the spec is fully satisfied.\n \n\n\n\n# [PROHIBITED]: Never Do This\n\n \n Pushing, under any circumstances.\n \n\n \n Committing an empty diff.\n \n\n\n\n" + } +} From 53d0ce8ab93fb50385bcfcf5461ab76cab8b257c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 19:47:52 -0500 Subject: [PATCH 011/180] fix(lint): lint the TypeScript sources and track eslint.config.js --- .gitignore | 2 + eslint.config.js | 15 ++ package-lock.json | 320 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 + worker/src/documents.ts | 1 - worker/test/api.test.ts | 3 +- 6 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 eslint.config.js diff --git a/.gitignore b/.gitignore index 905cbe0..a2519ae 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,8 @@ dist *.htm *.html *.js +# The ESLint flat config is source, not a build output. +!eslint.config.js *.jsp *.php *.rss diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..8e9d869 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["node_modules/", "dist/", ".wrangler/", "coverage/"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + rules: { + "no-console": ["error", { allow: ["error", "warn"] }], + }, + }, +); diff --git a/package-lock.json b/package-lock.json index c336365..f1edd5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", + "typescript-eslint": "8.71.2", "vitest": "^3.0.5", "wrangler": "^4.153.0" }, @@ -2543,6 +2544,288 @@ "undici-types": "~6.21.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -4019,6 +4302,19 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4073,6 +4369,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", diff --git a/package.json b/package.json index 35e5e72..fa88f2e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", + "typescript-eslint": "8.71.2", "vitest": "^3.0.5", "wrangler": "^4.153.0" } diff --git a/worker/src/documents.ts b/worker/src/documents.ts index a4995d3..3a744a0 100644 --- a/worker/src/documents.ts +++ b/worker/src/documents.ts @@ -116,7 +116,6 @@ export function sourcesFromTree( files: TreeFile[], ): DocumentSource[] { const documentRe = /^(graphs|nodes)\/([a-z0-9-]{1,48})\/(?:graph|node)\.json$/; - const readmeRe = /^(?:graphs|nodes)\/([a-z0-9-]{1,48})\/readme\.md$/i; return files.flatMap((file) => { const match = documentRe.exec(file.path); if (match === null) { diff --git a/worker/test/api.test.ts b/worker/test/api.test.ts index 6bf1404..4ead2b8 100644 --- a/worker/test/api.test.ts +++ b/worker/test/api.test.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; import { handleApi } from "../src/api.js"; -import { NOT_API } from "../src/api.js"; import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; -import { INVALID_GRAPH_TEXT, VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; +import { VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; const SEARCH = "search/repositories"; const REPO_DIR = "repos/UniverLab/graphs"; From 4a8697e581e5cde75ef704fc4cef3ab11fc9a1aa Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 20:09:59 -0500 Subject: [PATCH 012/180] refactor(repo): move the catalogue worker to apps/graphs and rename the repo to canopy-web --- .github/workflows/deploy.yml | 6 ++--- README.md | 23 ++++++++++++++----- {worker => apps/graphs}/README.md | 12 +++++----- {worker => apps/graphs}/src/analytics.ts | 0 {worker => apps/graphs}/src/api.ts | 2 +- {worker => apps/graphs}/src/documents.ts | 2 +- {worker => apps/graphs}/src/github.ts | 0 {worker => apps/graphs}/src/index.ts | 0 {worker => apps/graphs}/src/json.ts | 2 +- {worker => apps/graphs}/src/lib.ts | 4 ++-- {worker => apps/graphs}/src/official.ts | 4 ++-- {worker => apps/graphs}/src/pages/copy.ts | 2 +- {worker => apps/graphs}/src/pages/layout.ts | 0 {worker => apps/graphs}/src/pages/md.ts | 0 {worker => apps/graphs}/src/pages/ref.ts | 2 +- {worker => apps/graphs}/src/pages/routes.ts | 2 +- {worker => apps/graphs}/src/pages/svg.ts | 2 +- {worker => apps/graphs}/src/pages/theme.ts | 0 {worker => apps/graphs}/src/search.ts | 2 +- {worker => apps/graphs}/src/validate.ts | 2 +- .../test/__snapshots__/pages.test.ts.snap | 12 +++++----- .../graphs}/test/analytics.test.ts | 14 +++++------ {worker => apps/graphs}/test/api.test.ts | 22 +++++++++--------- {worker => apps/graphs}/test/cache.test.ts | 6 ++--- .../graphs}/test/documents-fixture.ts | 4 ++-- {worker => apps/graphs}/test/fakes.ts | 0 {worker => apps/graphs}/test/github.test.ts | 2 +- {worker => apps/graphs}/test/index.test.ts | 10 ++++---- {worker => apps/graphs}/test/official.test.ts | 10 ++++---- {worker => apps/graphs}/test/pages-fixture.ts | 0 {worker => apps/graphs}/test/pages.test.ts | 8 +++---- {worker => apps/graphs}/test/search.test.ts | 0 {worker => apps/graphs}/vitest.config.ts | 2 +- {worker => apps/graphs}/wrangler.toml | 0 package.json | 2 +- tsconfig.json | 6 ++--- 36 files changed, 88 insertions(+), 77 deletions(-) rename {worker => apps/graphs}/README.md (91%) rename {worker => apps/graphs}/src/analytics.ts (100%) rename {worker => apps/graphs}/src/api.ts (99%) rename {worker => apps/graphs}/src/documents.ts (99%) rename {worker => apps/graphs}/src/github.ts (100%) rename {worker => apps/graphs}/src/index.ts (100%) rename {worker => apps/graphs}/src/json.ts (98%) rename {worker => apps/graphs}/src/lib.ts (98%) rename {worker => apps/graphs}/src/official.ts (96%) rename {worker => apps/graphs}/src/pages/copy.ts (98%) rename {worker => apps/graphs}/src/pages/layout.ts (100%) rename {worker => apps/graphs}/src/pages/md.ts (100%) rename {worker => apps/graphs}/src/pages/ref.ts (97%) rename {worker => apps/graphs}/src/pages/routes.ts (99%) rename {worker => apps/graphs}/src/pages/svg.ts (99%) rename {worker => apps/graphs}/src/pages/theme.ts (100%) rename {worker => apps/graphs}/src/search.ts (99%) rename {worker => apps/graphs}/src/validate.ts (98%) rename {worker => apps/graphs}/test/__snapshots__/pages.test.ts.snap (98%) rename {worker => apps/graphs}/test/analytics.test.ts (88%) rename {worker => apps/graphs}/test/api.test.ts (92%) rename {worker => apps/graphs}/test/cache.test.ts (90%) rename {worker => apps/graphs}/test/documents-fixture.ts (81%) rename {worker => apps/graphs}/test/fakes.ts (100%) rename {worker => apps/graphs}/test/github.test.ts (99%) rename {worker => apps/graphs}/test/index.test.ts (96%) rename {worker => apps/graphs}/test/official.test.ts (93%) rename {worker => apps/graphs}/test/pages-fixture.ts (100%) rename {worker => apps/graphs}/test/pages.test.ts (98%) rename {worker => apps/graphs}/test/search.test.ts (100%) rename {worker => apps/graphs}/vitest.config.ts (89%) rename {worker => apps/graphs}/wrangler.toml (100%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cbabf7d..5534f06 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,8 @@ name: deploy -# Manual only: nothing deploys on merge. Publishes worker/ to Cloudflare as +# Manual only: nothing deploys on merge. Publishes apps/graphs/ to Cloudflare as # the `canopy-graphs` Worker behind graphs.univerlab.org. The account id is -# inlined in worker/wrangler.toml, so the only secret is the API token. +# inlined in apps/graphs/wrangler.toml, so the only secret is the API token. on: workflow_dispatch: {} @@ -26,5 +26,5 @@ jobs: uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - workingDirectory: worker + workingDirectory: apps/graphs command: deploy diff --git a/README.md b/README.md index 3d317d2..7383133 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ -# graphs.univerlab.org catalogue +# canopy-web -Shareable Canopy documents live here. Two kinds: +The web side of Canopy, one repository with one app per subdomain: + +- `apps/graphs/` — the graphs catalogue at graphs.univerlab.org: browse, + share and install graph and node designs. +- `apps/remote/` — Canopy remote control at remote.univerlab.org: reach your + canopy sessions from a browser, peer to peer. + +Each app is its own Cloudflare Worker with its own secrets and origin. + +## Graphs catalogue + +Shareable Canopy documents live in `registry/`. Two kinds: - **Graphs** — a graph's portable design (`GraphExportDocument`, format version 6): name, nodes, edges, ensembles, hooks, infra node, @@ -12,7 +23,7 @@ Shareable Canopy documents live here. Two kinds: ## Document layout ```text -graphs/ +canopy-web/ schema/ graph.schema.json # the graph contract (v6, bundled presets) node.schema.json # the node contract (v1) @@ -23,7 +34,7 @@ graphs/ registry/ graphs//graph.json + README.md nodes//node.json + README.md - worker/ # Cloudflare Worker serving the catalogue + apps/graphs/ # Cloudflare Worker serving the catalogue tests/ # vitest suite + fixtures ``` @@ -54,9 +65,9 @@ Canopy's `graph validate` agree on — import them, never copy them. ## Worker deploy -The install redirector lives in `worker/` (Cloudflare Worker +The install redirector lives in `apps/graphs/` (Cloudflare Worker `canopy-graphs`, behind `graphs.univerlab.org`). Deploys are manual only (`.github/workflows/deploy.yml`, `workflow_dispatch`). Before the first -deploy, follow the one-time setup in [worker/README.md](worker/README.md): +deploy, follow the one-time setup in [apps/graphs/README.md](apps/graphs/README.md): enable Analytics Engine on the account, bind the custom domain, and set `CLOUDFLARE_API_TOKEN`. diff --git a/worker/README.md b/apps/graphs/README.md similarity index 91% rename from worker/README.md rename to apps/graphs/README.md index 39613d5..149857b 100644 --- a/worker/README.md +++ b/apps/graphs/README.md @@ -15,7 +15,7 @@ anchor — this Worker is just a prettier doorway. | Route | Where it points | |---|---| -| `GET /i//` | official: `https://raw.githubusercontent.com/UniverLab/graphs//registry/s//.json` | +| `GET /i//` | official: `https://raw.githubusercontent.com/UniverLab/canopy-web//registry/s//.json` | | `GET /i////` | community: `https://raw.githubusercontent.com////s//.json` | - `` is `graph` or `node`. @@ -27,7 +27,7 @@ anchor — this Worker is just a prettier doorway. Official documents are the ones in this repo's `registry/` — the `registry/` prefix is implicit in the short route, so `/i/graph/hello-graph` and the -community form `/i/UniverLab/graphs/graph/hello-graph` point at the same file. +community form `/i/UniverLab/canopy-web/graph/hello-graph` point at the same file. ## What it counts @@ -36,9 +36,9 @@ One aggregate data point per request, via | Field | Example | Why | |---|---|---| -| index | `UniverLab/graphs/graph/hello-graph` | which document is installed | +| index | `UniverLab/canopy-web/graph/hello-graph` | which document is installed | | kind | `graph` / `node` | graphs vs nodes | -| document | `UniverLab/graphs/hello-graph` | document, without repeating the kind | +| document | `UniverLab/canopy-web/hello-graph` | document, without repeating the kind | | client | `canopy`, `curl`, `browser`, `other` | a real install, a script, or someone looking | | canopy version | `1.7.2` | which Canopy release installs what | | country | `CO` | rough geography | @@ -78,7 +78,7 @@ absent when not: counts are never zero-filled. ## Tests ```sh -npm run test:worker # worker/ suite, in the Workers runtime via Miniflare +npm run test:worker # apps/graphs suite, in the Workers runtime via Miniflare ``` The suite covers both route shapes, `?ref=`, every 400 case, the `/i/` usage @@ -87,7 +87,7 @@ binding still serves the redirect, plus the API surface above: official listing, search with text and both orderings, invalid documents dropped, a cache hit on a second call, rate limits surfaced as typed errors, and counts present and absent. `npm test` runs it together with the document validator -suite. The API tests use injected fakes (`worker/test/fakes.ts`), +suite. The API tests use injected fakes (`apps/graphs/test/fakes.ts`), never stubbed globals. ## One-time setup (before the first deploy) diff --git a/worker/src/analytics.ts b/apps/graphs/src/analytics.ts similarity index 100% rename from worker/src/analytics.ts rename to apps/graphs/src/analytics.ts diff --git a/worker/src/api.ts b/apps/graphs/src/api.ts similarity index 99% rename from worker/src/api.ts rename to apps/graphs/src/api.ts index 7cf3beb..08b2054 100644 --- a/worker/src/api.ts +++ b/apps/graphs/src/api.ts @@ -14,7 +14,7 @@ */ import { attachDownloads, createAnalytics } from "./analytics.js"; import { createGitHub, isApiError, type ApiError, type CacheLike, type GitHub, type Log } from "./github.js"; -import type { DocumentKind } from "../../src/schema.js"; +import type { DocumentKind } from "../../../src/schema.js"; import { SLUG_RE, OWNER_RE, REPO_RE, isOfficialRepo } from "./lib.js"; import { documentDirectory, loadDocument } from "./documents.js"; import { listOfficial, loadOfficialDocument } from "./official.js"; diff --git a/worker/src/documents.ts b/apps/graphs/src/documents.ts similarity index 99% rename from worker/src/documents.ts rename to apps/graphs/src/documents.ts index 3a744a0..fcfe22f 100644 --- a/worker/src/documents.ts +++ b/apps/graphs/src/documents.ts @@ -9,7 +9,7 @@ * fetched once the document validated) and dropping anything invalid with a * note in the log instead of an error. */ -import type { DocumentKind, JsonValue } from "../../src/schema.js"; +import type { DocumentKind, JsonValue } from "../../../src/schema.js"; import type { ApiError, GitHub } from "./github.js"; import { isApiError } from "./github.js"; import { decodeBase64, isArray, isNumber, isRecord, isString, parseJson, readJson, stringField } from "./json.js"; diff --git a/worker/src/github.ts b/apps/graphs/src/github.ts similarity index 100% rename from worker/src/github.ts rename to apps/graphs/src/github.ts diff --git a/worker/src/index.ts b/apps/graphs/src/index.ts similarity index 100% rename from worker/src/index.ts rename to apps/graphs/src/index.ts diff --git a/worker/src/json.ts b/apps/graphs/src/json.ts similarity index 98% rename from worker/src/json.ts rename to apps/graphs/src/json.ts index a1fdcd4..dd63821 100644 --- a/worker/src/json.ts +++ b/apps/graphs/src/json.ts @@ -7,7 +7,7 @@ * values the callers can branch on, so a malformed GitHub response degrades * into a typed error or a dropped document instead of an exception. */ -import type { JsonValue } from "../../src/schema.js"; +import type { JsonValue } from "../../../src/schema.js"; export type { JsonValue }; diff --git a/worker/src/lib.ts b/apps/graphs/src/lib.ts similarity index 98% rename from worker/src/lib.ts rename to apps/graphs/src/lib.ts index 5cd140b..777f193 100644 --- a/worker/src/lib.ts +++ b/apps/graphs/src/lib.ts @@ -36,7 +36,7 @@ export const DEFAULT_REF = "HEAD"; export const OFFICIAL_OWNER = "UniverLab"; -export const OFFICIAL_REPO = "graphs"; +export const OFFICIAL_REPO = "canopy-web"; /** Official documents live under the registry layout; community repos do not. */ const OFFICIAL_PREFIX = "registry/"; @@ -66,7 +66,7 @@ const UNKNOWN = "?"; export const USAGE = [ "graphs.univerlab.org redirects to the document on GitHub.", "", - " GET /i// official: UniverLab/graphs", + " GET /i// official: UniverLab/canopy-web", " GET /i//// community repository", "", " is graph or node; ?ref= defaults to HEAD.", diff --git a/worker/src/official.ts b/apps/graphs/src/official.ts similarity index 96% rename from worker/src/official.ts rename to apps/graphs/src/official.ts index 74dd091..1d31783 100644 --- a/worker/src/official.ts +++ b/apps/graphs/src/official.ts @@ -2,12 +2,12 @@ /** * The official listing for graphs.univerlab.org. * - * UniverLab/graphs is the database: `registry/graphs/` and `registry/nodes/` + * UniverLab/canopy-web is the database: `registry/graphs/` and `registry/nodes/` * hold one folder per document. This module lists the two directories, then * loads each folder through the same validate-and-render path as everything * else. Nothing is persisted beyond the Cache API. */ -import type { DocumentKind, JsonValue } from "../../src/schema.js"; +import type { DocumentKind, JsonValue } from "../../../src/schema.js"; import { type ApiError, type GitHub, diff --git a/worker/src/pages/copy.ts b/apps/graphs/src/pages/copy.ts similarity index 98% rename from worker/src/pages/copy.ts rename to apps/graphs/src/pages/copy.ts index fd31e2d..c03b889 100644 --- a/worker/src/pages/copy.ts +++ b/apps/graphs/src/pages/copy.ts @@ -7,7 +7,7 @@ * one file. The few string *shapes* (an install command, an llms.txt line) * live here too, next to the words they are made of. */ -import type { DocumentKind } from "../../../src/schema.js"; +import type { DocumentKind } from "../../../../src/schema.js"; import type { Doc } from "../documents.js"; /** The site's name, in the header and the page titles. */ diff --git a/worker/src/pages/layout.ts b/apps/graphs/src/pages/layout.ts similarity index 100% rename from worker/src/pages/layout.ts rename to apps/graphs/src/pages/layout.ts diff --git a/worker/src/pages/md.ts b/apps/graphs/src/pages/md.ts similarity index 100% rename from worker/src/pages/md.ts rename to apps/graphs/src/pages/md.ts diff --git a/worker/src/pages/ref.ts b/apps/graphs/src/pages/ref.ts similarity index 97% rename from worker/src/pages/ref.ts rename to apps/graphs/src/pages/ref.ts index 7c61ac5..0ea06b6 100644 --- a/worker/src/pages/ref.ts +++ b/apps/graphs/src/pages/ref.ts @@ -8,7 +8,7 @@ * notion of "official" (`isOfficialRepo`) so a page and an install command * can never disagree. */ -import type { DocumentKind } from "../../../src/schema.js"; +import type { DocumentKind } from "../../../../src/schema.js"; import type { Doc } from "../documents.js"; import { isOfficialRepo } from "../lib.js"; diff --git a/worker/src/pages/routes.ts b/apps/graphs/src/pages/routes.ts similarity index 99% rename from worker/src/pages/routes.ts rename to apps/graphs/src/pages/routes.ts index 9303610..713a97b 100644 --- a/worker/src/pages/routes.ts +++ b/apps/graphs/src/pages/routes.ts @@ -20,7 +20,7 @@ import { type ApiDeps, type ApiEnv } from "../api.js"; import { attachDownloads, createAnalytics, type Downloads } from "../analytics.js"; import { type Log, createGitHub, isApiError } from "../github.js"; import { OFFICIAL_OWNER, OFFICIAL_REPO, OWNER_RE, REPO_RE, SLUG_RE } from "../lib.js"; -import type { DocumentKind, JsonValue } from "../../../src/schema.js"; +import type { DocumentKind, JsonValue } from "../../../../src/schema.js"; import type { Doc } from "../documents.js"; import { documentDirectory, loadDocument } from "../documents.js"; import { listOfficial, loadOfficialDocument } from "../official.js"; diff --git a/worker/src/pages/svg.ts b/apps/graphs/src/pages/svg.ts similarity index 99% rename from worker/src/pages/svg.ts rename to apps/graphs/src/pages/svg.ts index 1e82e41..0ba0c6a 100644 --- a/worker/src/pages/svg.ts +++ b/apps/graphs/src/pages/svg.ts @@ -9,7 +9,7 @@ * left-to-right columns, and within a column each node takes the next row. * The whole drawing is static SVG: no script, no external anything. */ -import type { JsonValue } from "../../../src/schema.js"; +import type { JsonValue } from "../../../../src/schema.js"; import { isArray, isRecord, isString, stringField } from "../json.js"; import { DRAWING_ALT } from "./copy.js"; import { escapeHtml } from "./md.js"; diff --git a/worker/src/pages/theme.ts b/apps/graphs/src/pages/theme.ts similarity index 100% rename from worker/src/pages/theme.ts rename to apps/graphs/src/pages/theme.ts diff --git a/worker/src/search.ts b/apps/graphs/src/search.ts similarity index 99% rename from worker/src/search.ts rename to apps/graphs/src/search.ts index c57f094..9d2041c 100644 --- a/worker/src/search.ts +++ b/apps/graphs/src/search.ts @@ -8,7 +8,7 @@ * catalogue never re-sorts. The document cap keeps a request bounded, and a * bounded-away page is reported with `truncated: true`. */ -import type { DocumentKind } from "../../src/schema.js"; +import type { DocumentKind } from "../../../src/schema.js"; import type { ApiError, GitHub, Log } from "./github.js"; import { isApiError } from "./github.js"; import { isArray, isNumber, isRecord, isString, readJson, stringField } from "./json.js"; diff --git a/worker/src/validate.ts b/apps/graphs/src/validate.ts similarity index 98% rename from worker/src/validate.ts rename to apps/graphs/src/validate.ts index 12a02ad..46d6731 100644 --- a/worker/src/validate.ts +++ b/apps/graphs/src/validate.ts @@ -8,7 +8,7 @@ * never a copied version of the rules. */ import { type Schema, Validator } from "@cfworker/json-schema"; -import { graphSchema, nodeSchema, type DocumentKind } from "../../src/schema.js"; +import { graphSchema, nodeSchema, type DocumentKind } from "../../../src/schema.js"; import { isRecord, type ParsedJson, parseJson } from "./json.js"; interface SchemaResult { diff --git a/worker/test/__snapshots__/pages.test.ts.snap b/apps/graphs/test/__snapshots__/pages.test.ts.snap similarity index 98% rename from worker/test/__snapshots__/pages.test.ts.snap rename to apps/graphs/test/__snapshots__/pages.test.ts.snap index 54a9b49..b1f19d8 100644 --- a/worker/test/__snapshots__/pages.test.ts.snap +++ b/apps/graphs/test/__snapshots__/pages.test.ts.snap @@ -507,8 +507,8 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; }

    hello-graph

    Five nodes with one fail edge, for the drawing test.

    -

    UniverLab/graphs — 7 stars

    -

    Star on GitHub

    +

    UniverLab/canopy-web — 7 stars

    +

    Star on GitHub

    Install

    canopy graph install hello-graph

    Drawing

    buildtestgatetriagedocs @@ -695,8 +695,8 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; }

    hello-node

    Example catalogue node: drafts an implementation plan.

    -

    UniverLab/graphs — 7 stars

    -

    Star on GitHub

    +

    UniverLab/canopy-web — 7 stars

    +

    Star on GitHub

    Install

    canopy node install hello-node
    @@ -1041,8 +1041,8 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; }

    hello-graph

    Five nodes with one fail edge, for the drawing test.

    -

    UniverLab/graphs — 7 stars

    -

    Star on GitHub

    +

    UniverLab/canopy-web — 7 stars

    +

    Star on GitHub

    Install

    canopy graph install hello-graph

    Drawing

    buildtestgatetriagedocs diff --git a/worker/test/analytics.test.ts b/apps/graphs/test/analytics.test.ts similarity index 88% rename from worker/test/analytics.test.ts rename to apps/graphs/test/analytics.test.ts index b74b473..80eee8c 100644 --- a/worker/test/analytics.test.ts +++ b/apps/graphs/test/analytics.test.ts @@ -41,7 +41,7 @@ describe("the analytics collector", () => { status: 200, body: { data: [ - { index: "UniverLab/graphs/graph/hello-graph", count: 12 }, + { index: "UniverLab/canopy-web/graph/hello-graph", count: 12 }, { index: "acme/things/node/cool-node", count: 4 }, ], }, @@ -54,7 +54,7 @@ describe("the analytics collector", () => { token: "tok", }); const volumes = (await analytics.volume()) as Downloads; - expect(volumes.get("UniverLab/graphs/graph/hello-graph")).toBe(12); + expect(volumes.get("UniverLab/canopy-web/graph/hello-graph")).toBe(12); expect(volumes.get("acme/things/node/cool-node")).toBe(4); expect(fake.calls.length).toBe(1); expect(fake.calls[0]).toContain("accounts/acct/analytics_engine/sql"); @@ -68,7 +68,7 @@ describe("the analytics collector", () => { it("serves the second call from the cache without a second SQL call", async () => { const fake = fakeFetch([ - { url: SQL_URL, status: 200, body: { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 12 }] } }, + { url: SQL_URL, status: 200, body: { data: [{ index: "UniverLab/canopy-web/graph/hello-graph", count: 12 }] } }, ]); const cache = new FakeCache(); const analytics = createAnalytics({ @@ -81,7 +81,7 @@ describe("the analytics collector", () => { const again = await analytics.volume(); expect(fake.calls.length).toBe(1); expect(cache.has(ANALYTICS_CACHE_URL)).toBe(true); - expect((again as Downloads).get("UniverLab/graphs/graph/hello-graph")).toBe(12); + expect((again as Downloads).get("UniverLab/canopy-web/graph/hello-graph")).toBe(12); }); it("returns null when the SQL call fails, never a throw", async () => { @@ -98,8 +98,8 @@ describe("the analytics collector", () => { it("attaches 90-day counts where they match and nothing where they do not", () => { const docs = [ - sampleDoc("hello-graph", "graph", "UniverLab/graphs"), - sampleDoc("quiet-graph", "graph", "UniverLab/graphs"), + sampleDoc("hello-graph", "graph", "UniverLab/canopy-web"), + sampleDoc("quiet-graph", "graph", "UniverLab/canopy-web"), ]; const volumes = new Map([[downloadKey(docs[0]), 12]]); const attached = attachDownloads(docs, volumes); @@ -109,7 +109,7 @@ describe("the analytics collector", () => { }); it("leaves downloads absent altogether when analytics is unconfigured", () => { - const attached = attachDownloads([sampleDoc("hello-graph", "graph", "UniverLab/graphs")], null); + const attached = attachDownloads([sampleDoc("hello-graph", "graph", "UniverLab/canopy-web")], null); expect(attached[0].downloads).toBeUndefined(); }); }); diff --git a/worker/test/api.test.ts b/apps/graphs/test/api.test.ts similarity index 92% rename from worker/test/api.test.ts rename to apps/graphs/test/api.test.ts index 4ead2b8..c956546 100644 --- a/worker/test/api.test.ts +++ b/apps/graphs/test/api.test.ts @@ -5,7 +5,7 @@ import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; import { VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; const SEARCH = "search/repositories"; -const REPO_DIR = "repos/UniverLab/graphs"; +const REPO_DIR = "repos/UniverLab/canopy-web"; /** Everything one official graph plus one node need to answer an API call. */ function stubs(): Stub[] { @@ -16,7 +16,7 @@ function stubs(): Stub[] { { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n") }, { url: `${REPO_DIR}/contents/registry/nodes/hello-node/node.json`, status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, { url: `${REPO_DIR}/contents/registry/nodes/hello-node/README.md`, status: 404, body: {} }, - { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + { url: "repos/UniverLab/canopy-web", status: 200, body: { stargazers_count: 7 } }, ]; } @@ -86,7 +86,7 @@ describe("the JSON API", () => { }); it("answers /api/g/ with the validated document attached", async () => { - const response = await call("/api/g/UniverLab/graphs/hello-graph"); + const response = await call("/api/g/UniverLab/canopy-web/hello-graph"); expect(response.status).toBe(200); const body = (await response.json()) as { doc: { kind: string; slug: string; repo: string }; @@ -121,7 +121,7 @@ describe("the JSON API", () => { }, ]); const answer = await handleApi( - new Request("https://graphs.univerlab.org/api/n/UniverLab/graphs/hello-graph"), + new Request("https://graphs.univerlab.org/api/n/UniverLab/canopy-web/hello-graph"), {}, { fetchImpl: fake.fetchImpl, cache: new FakeCache() }, ); @@ -142,7 +142,7 @@ describe("the JSON API", () => { expect(listing.status).toBe(502); expect(((await listing.json()) as { kind: string }).kind).toBe("upstream"); - const document = await call("/api/g/UniverLab/graphs/hello-graph", { + const document = await call("/api/g/UniverLab/canopy-web/hello-graph", { stubs: [ { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, @@ -158,12 +158,12 @@ describe("the JSON API", () => { it("answers a missing document with a typed 404, never a 500", async () => { const fake = fakeFetch([ - { url: "repos/UniverLab/graphs/contents/registry/graphs/missing/graph.json", status: 404, body: {} }, - { url: "repos/UniverLab/graphs/contents/registry/nodes/missing/node.json", status: 404, body: {} }, + { url: "repos/UniverLab/canopy-web/contents/registry/graphs/missing/graph.json", status: 404, body: {} }, + { url: "repos/UniverLab/canopy-web/contents/registry/nodes/missing/node.json", status: 404, body: {} }, ]); const cache = new FakeCache(); const answer = await handleApi( - new Request("https://graphs.univerlab.org/api/g/UniverLab/graphs/missing"), + new Request("https://graphs.univerlab.org/api/g/UniverLab/canopy-web/missing"), {}, { fetchImpl: fake.fetchImpl, cache }, ); @@ -227,7 +227,7 @@ describe("the JSON API", () => { { url: "analytics_engine/sql", status: 200, - body: { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 5 }] }, + body: { data: [{ index: "UniverLab/canopy-web/graph/hello-graph", count: 5 }] }, }, ], }); @@ -239,8 +239,8 @@ describe("the JSON API", () => { }); it("does not list official stars as zero when the repo call fails", async () => { - const withoutStars = stubs().filter((stub) => stub.url !== "repos/UniverLab/graphs"); - withoutStars.push({ url: "repos/UniverLab/graphs", status: 404, body: {}, }); + const withoutStars = stubs().filter((stub) => stub.url !== "repos/UniverLab/canopy-web"); + withoutStars.push({ url: "repos/UniverLab/canopy-web", status: 404, body: {}, }); const response = await call("/api/official", { stubs: withoutStars }); const body = (await response.json()) as { graphs: Array<{ stars: unknown }> }; expect(response.status).toBe(200); diff --git a/worker/test/cache.test.ts b/apps/graphs/test/cache.test.ts similarity index 90% rename from worker/test/cache.test.ts rename to apps/graphs/test/cache.test.ts index d606a37..27e6093 100644 --- a/worker/test/cache.test.ts +++ b/apps/graphs/test/cache.test.ts @@ -5,14 +5,14 @@ import { listOfficial } from "../src/official.js"; import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; import { VALID_GRAPH_TEXT } from "./documents-fixture.js"; -const REPO_DIR = "repos/UniverLab/graphs"; +const REPO_DIR = "repos/UniverLab/canopy-web"; const STUBS: Stub[] = [ { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: "hello-graph", type: "dir" }]) }, { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: [] }, { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", VALID_GRAPH_TEXT) }, { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n") }, - { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + { url: "repos/UniverLab/canopy-web", status: 200, body: { stargazers_count: 7 } }, ]; describe("the Cache API contract", () => { @@ -35,7 +35,7 @@ describe("the Cache API contract", () => { }); it("caches analytics results for an hour", async () => { - const analyticsBody = { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 3 }] }; + const analyticsBody = { data: [{ index: "UniverLab/canopy-web/graph/hello-graph", count: 3 }] }; const fake = fakeFetch([ { url: "analytics_engine/sql", status: 200, body: analyticsBody }, ]); diff --git a/worker/test/documents-fixture.ts b/apps/graphs/test/documents-fixture.ts similarity index 81% rename from worker/test/documents-fixture.ts rename to apps/graphs/test/documents-fixture.ts index 552eef7..f815122 100644 --- a/worker/test/documents-fixture.ts +++ b/apps/graphs/test/documents-fixture.ts @@ -4,8 +4,8 @@ * node and a broken one, straight from `registry/` (the published examples * are the contract) plus a hand-broken neighbour. */ -import validGraphJson from "../../registry/graphs/hello-graph/graph.json" with { type: "json" }; -import validNodeJson from "../../registry/nodes/hello-node/node.json" with { type: "json" }; +import validGraphJson from "../../../registry/graphs/hello-graph/graph.json" with { type: "json" }; +import validNodeJson from "../../../registry/nodes/hello-node/node.json" with { type: "json" }; /** The published hello-graph document, as GitHub would answer it serialized. */ export const VALID_GRAPH_TEXT = JSON.stringify(validGraphJson); diff --git a/worker/test/fakes.ts b/apps/graphs/test/fakes.ts similarity index 100% rename from worker/test/fakes.ts rename to apps/graphs/test/fakes.ts diff --git a/worker/test/github.test.ts b/apps/graphs/test/github.test.ts similarity index 99% rename from worker/test/github.test.ts rename to apps/graphs/test/github.test.ts index 8909940..72b51af 100644 --- a/worker/test/github.test.ts +++ b/apps/graphs/test/github.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { createGitHub, TTL, USER_AGENT, type ApiError } from "../src/github.js"; import { FakeCache, fakeFetch, type Stub } from "./fakes.js"; -const url = "https://api.github.com/repos/UniverLab/graphs"; +const url = "https://api.github.com/repos/UniverLab/canopy-web"; function run(stubs: Stub[], options: { token?: string } = {}): { calls: string[]; diff --git a/worker/test/index.test.ts b/apps/graphs/test/index.test.ts similarity index 96% rename from worker/test/index.test.ts rename to apps/graphs/test/index.test.ts index cccf52f..47311a6 100644 --- a/worker/test/index.test.ts +++ b/apps/graphs/test/index.test.ts @@ -7,7 +7,7 @@ import { SLUG_PATTERN, SLUG_RE } from "../src/lib.js"; const ORIGIN = "https://graphs.univerlab.org"; const official = (kind: string, slug: string, ref = "HEAD"): string => - `https://raw.githubusercontent.com/UniverLab/graphs/${ref}/registry/${kind}s/${slug}/${kind}.json`; + `https://raw.githubusercontent.com/UniverLab/canopy-web/${ref}/registry/${kind}s/${slug}/${kind}.json`; const community = (owner: string, repo: string, kind: string, slug: string, ref = "HEAD"): string => `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${kind}s/${slug}/${kind}.json`; @@ -159,8 +159,8 @@ describe("counting", () => { expect(response.status).toBe(302); expect(points).toHaveLength(1); expect(points[0]).toEqual({ - indexes: ["UniverLab/graphs/graph/hello-graph"], - blobs: ["graph", "UniverLab/graphs/hello-graph", "curl", "", "", "redirect"], + indexes: ["UniverLab/canopy-web/graph/hello-graph"], + blobs: ["graph", "UniverLab/canopy-web/hello-graph", "curl", "", "", "redirect"], doubles: [1], }); }); @@ -200,8 +200,8 @@ describe("counting", () => { expect(response.status).toBe(400); expect(points).toEqual([ { - indexes: ["UniverLab/graphs/graph/?"], - blobs: ["graph", "UniverLab/graphs/?", "curl", "", "", "bad-request"], + indexes: ["UniverLab/canopy-web/graph/?"], + blobs: ["graph", "UniverLab/canopy-web/?", "curl", "", "", "bad-request"], doubles: [1], }, ]); diff --git a/worker/test/official.test.ts b/apps/graphs/test/official.test.ts similarity index 93% rename from worker/test/official.test.ts rename to apps/graphs/test/official.test.ts index a35b5c2..0db9b46 100644 --- a/worker/test/official.test.ts +++ b/apps/graphs/test/official.test.ts @@ -6,7 +6,7 @@ import { createGitHub } from "../src/github.js"; import { FakeCache, dirBody, fakeFetch, fileBody } from "./fakes.js"; import { INVALID_GRAPH_TEXT, VALID_GRAPH_TEXT } from "./documents-fixture.js"; -const REPO_DIR = "repos/UniverLab/graphs"; +const REPO_DIR = "repos/UniverLab/canopy-web"; /** The GitHub-shaped stubs behind one and two official documents. */ const STUBS = (brokenToo: boolean) => [ @@ -24,7 +24,7 @@ const STUBS = (brokenToo: boolean) => [ { url: `${REPO_DIR}/contents/registry/graphs/broken-graph/graph.json`, status: 200, body: fileBody("graphs/broken-graph/graph.json", INVALID_GRAPH_TEXT) }, { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", "# hello-graph\n\nA tiny graph.\n") }, { url: `${REPO_DIR}/contents/registry/graphs/broken-graph/README.md`, status: 404, body: {} }, - { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + { url: "repos/UniverLab/canopy-web", status: 200, body: { stargazers_count: 7 } }, ]; describe("the official listing", () => { @@ -42,7 +42,7 @@ describe("the official listing", () => { expect(doc.name).toBe("hello-graph"); expect(doc.readme).toContain("A tiny graph"); expect(doc.nodeCount).toBe(4); - expect(doc.repo).toBe("UniverLab/graphs"); + expect(doc.repo).toBe("UniverLab/canopy-web"); expect(doc.stars).toBe(7); expect("downloads" in doc).toBe(false); }); @@ -85,12 +85,12 @@ describe("the official listing", () => { }, { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 404, body: {} }, { - url: "raw.githubusercontent.com/UniverLab/graphs/HEAD/registry/graphs/hello-graph/graph.json", + url: "raw.githubusercontent.com/UniverLab/canopy-web/HEAD/registry/graphs/hello-graph/graph.json", status: 200, text: VALID_GRAPH_TEXT, headers: { "content-type": "text/plain" }, }, - { url: "repos/UniverLab/graphs", status: 200, body: { stargazers_count: 7 } }, + { url: "repos/UniverLab/canopy-web", status: 200, body: { stargazers_count: 7 } }, ]); const github = createGitHub({ fetchImpl: fake.fetchImpl, cache: new FakeCache() }); const listing = (await listOfficial(github, () => undefined)) as OfficialListing; diff --git a/worker/test/pages-fixture.ts b/apps/graphs/test/pages-fixture.ts similarity index 100% rename from worker/test/pages-fixture.ts rename to apps/graphs/test/pages-fixture.ts diff --git a/worker/test/pages.test.ts b/apps/graphs/test/pages.test.ts similarity index 98% rename from worker/test/pages.test.ts rename to apps/graphs/test/pages.test.ts index 9bd2413..2199a45 100644 --- a/worker/test/pages.test.ts +++ b/apps/graphs/test/pages.test.ts @@ -16,7 +16,7 @@ import { import { VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; const ORIGIN = "https://graphs.univerlab.org"; -const REPO_DIR = "repos/UniverLab/graphs"; +const REPO_DIR = "repos/UniverLab/canopy-web"; const SEARCH = "search/repositories"; const HTML = { "content-type": "text/html" }; @@ -234,12 +234,12 @@ describe("the page shell", () => { env: { CF_ACCOUNT_ID: "acct", CF_ANALYTICS_TOKEN: "tok" }, stubs: [ ...officialStubs(), - { url: "analytics_engine/sql", status: 200, body: { data: [{ index: "UniverLab/graphs/graph/hello-graph", count: 12 }] } }, + { url: "analytics_engine/sql", status: 200, body: { data: [{ index: "UniverLab/canopy-web/graph/hello-graph", count: 12 }] } }, ], })).text(); expect(html).toContain("7 stars"); expect(html).toContain("12 downloads"); - expect(html).toContain(`Star on GitHub`); + expect(html).toContain(`Star on GitHub`); expect(html).toContain("build, docs"); expect(html).toContain("need a harness"); }); @@ -257,7 +257,7 @@ describe("the page shell", () => { }); it("serves the short official form and the long one identically", async () => { - const long = await call("/g/UniverLab/graphs/hello-graph"); + const long = await call("/g/UniverLab/canopy-web/hello-graph"); const html = await long.text(); expect(html).toContain("canopy graph install hello-graph"); }); diff --git a/worker/test/search.test.ts b/apps/graphs/test/search.test.ts similarity index 100% rename from worker/test/search.test.ts rename to apps/graphs/test/search.test.ts diff --git a/worker/vitest.config.ts b/apps/graphs/vitest.config.ts similarity index 89% rename from worker/vitest.config.ts rename to apps/graphs/vitest.config.ts index c0c0db3..c394267 100644 --- a/worker/vitest.config.ts +++ b/apps/graphs/vitest.config.ts @@ -3,7 +3,7 @@ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; export default defineWorkersConfig({ test: { - include: ["worker/test/**/*.test.ts"], + include: ["apps/graphs/test/**/*.test.ts"], poolOptions: { workers: { // Single-file Worker: the pool loads this config, so tests speak to diff --git a/worker/wrangler.toml b/apps/graphs/wrangler.toml similarity index 100% rename from worker/wrangler.toml rename to apps/graphs/wrangler.toml diff --git a/package.json b/package.json index fa88f2e..1f3f8d7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run && npm run test:worker", - "test:worker": "vitest run --config worker/vitest.config.ts", + "test:worker": "vitest run --config apps/graphs/vitest.config.ts", "validate": "tsx src/validate.ts" }, "dependencies": { diff --git a/tsconfig.json b/tsconfig.json index 8d07965..4abbeae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,8 @@ "src/**/*.ts", "tests/**/*.ts", "vitest.config.ts", - "worker/src/**/*.ts", - "worker/test/**/*.ts", - "worker/vitest.config.ts" + "apps/*/src/**/*.ts", + "apps/*/test/**/*.ts", + "apps/*/vitest.config.ts" ] } From f0bd6323b336a572289c2fe6ab198a2fe2a58f89 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 20:43:12 -0500 Subject: [PATCH 013/180] feat(relay): run a blind signaling relay with GitHub sign-in on remote.univerlab.org --- .github/workflows/deploy-remote.yml | 31 +++ apps/remote/worker/README.md | 77 ++++++ apps/remote/worker/src/auth.ts | 244 +++++++++++++++++ apps/remote/worker/src/codec.ts | 106 ++++++++ apps/remote/worker/src/github.ts | 139 ++++++++++ apps/remote/worker/src/index.ts | 66 +++++ apps/remote/worker/src/protocol.ts | 78 ++++++ apps/remote/worker/src/room.ts | 264 ++++++++++++++++++ apps/remote/worker/src/turn.ts | 104 +++++++ apps/remote/worker/src/ws.ts | 109 ++++++++ apps/remote/worker/test/auth.test.ts | 217 +++++++++++++++ apps/remote/worker/test/fakes.ts | 121 +++++++++ apps/remote/worker/test/headers.test.ts | 83 ++++++ apps/remote/worker/test/host.test.ts | 185 +++++++++++++ apps/remote/worker/test/room.test.ts | 345 ++++++++++++++++++++++++ apps/remote/worker/test/turn.test.ts | 94 +++++++ apps/remote/worker/test/vite-raw.d.ts | 5 + apps/remote/worker/vitest.config.ts | 19 ++ apps/remote/worker/wrangler.toml | 25 ++ package.json | 3 +- tsconfig.json | 5 +- 21 files changed, 2318 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy-remote.yml create mode 100644 apps/remote/worker/README.md create mode 100644 apps/remote/worker/src/auth.ts create mode 100644 apps/remote/worker/src/codec.ts create mode 100644 apps/remote/worker/src/github.ts create mode 100644 apps/remote/worker/src/index.ts create mode 100644 apps/remote/worker/src/protocol.ts create mode 100644 apps/remote/worker/src/room.ts create mode 100644 apps/remote/worker/src/turn.ts create mode 100644 apps/remote/worker/src/ws.ts create mode 100644 apps/remote/worker/test/auth.test.ts create mode 100644 apps/remote/worker/test/fakes.ts create mode 100644 apps/remote/worker/test/headers.test.ts create mode 100644 apps/remote/worker/test/host.test.ts create mode 100644 apps/remote/worker/test/room.test.ts create mode 100644 apps/remote/worker/test/turn.test.ts create mode 100644 apps/remote/worker/test/vite-raw.d.ts create mode 100644 apps/remote/worker/vitest.config.ts create mode 100644 apps/remote/worker/wrangler.toml diff --git a/.github/workflows/deploy-remote.yml b/.github/workflows/deploy-remote.yml new file mode 100644 index 0000000..e8787c3 --- /dev/null +++ b/.github/workflows/deploy-remote.yml @@ -0,0 +1,31 @@ +name: deploy-remote + +# Manual only: nothing deploys on merge. Publishes apps/remote/worker/ to +# Cloudflare as the `canopy-remote` Worker behind remote.univerlab.org. The +# account id is inlined in apps/remote/worker/wrangler.toml, so the only +# secret is the API token. +on: + workflow_dispatch: {} + +concurrency: + group: deploy-remote-worker + cancel-in-progress: false + +jobs: + deploy: + name: Deploy worker + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Deploy with Wrangler + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + workingDirectory: apps/remote/worker + command: deploy diff --git a/apps/remote/worker/README.md b/apps/remote/worker/README.md new file mode 100644 index 0000000..aafa276 --- /dev/null +++ b/apps/remote/worker/README.md @@ -0,0 +1,77 @@ +# remote.univerlab.org — blind signaling relay + +A tiny Cloudflare Worker that connects a browser to the user's own Canopy +host over WebRTC. It does exactly two things: prove both ends belong to the +same GitHub account, and forward opaque signaling messages between them. + +## Routes + +| Route | Answer | +|---|---| +| `GET /auth/login` | 302 to GitHub's authorize page (identity only, no scope) | +| `GET /auth/callback` | exchanges the code, sets the `cr_session` cookie, 302 to `/` | +| `POST /auth/logout` | clears the session cookie, 303 to `/` | +| `GET /auth/me` | `{login}` for the session cookie, else 401 | +| `GET /ws/device` | upgrades a browser socket into the user's room (cookie) | +| `GET /ws/host` | upgrades a host socket into the user's room (GitHub token + id + label) | +| `GET /turn` | `{iceServers}`: Cloudflare TURN credentials plus STUN | + +Anything else answers `404` for now; a defined path with the wrong method +answers `405`. Every response carries the relay's security headers +(CSP, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`). + +One Durable Object (`UserRoom`) per GitHub user id forwards envelopes +between that user's devices and hosts: a device frame `{to:"h:", +body}` reaches the host as `{from:"d:", body}`, and back. Devices +also get `{t:"hosts"}` whenever the host list changes. Caps per room: +frames over 64 KiB are dropped, 8 device sockets at most, 20 `pair_init` +bodies per 10 minutes. + +## What it stores: nothing + +No KV, no D1, no R2, no Durable Object storage. Room state is per-socket +attachments plus two in-memory fields; when the sockets close, nothing of +the user survives. The GitHub token is discarded after `/user`, and host +token verifications live at most 5 minutes in isolate memory. + +## Tests + +```sh +npm run test:remote-worker # apps/remote/worker suite, in the Workers runtime via Miniflare +``` + +The suite covers session sign/verify and tamper rejection, OAuth state +mismatch, host checks with a mocked GitHub, routing both ways, the hosts +list on connect/disconnect, host replacement, every limit, `/turn` with a +mocked Cloudflare API, and that the room never touches the storage API. +`npm test` runs it together with the validator and catalogue suites. Tests +use injected fakes (`apps/remote/worker/test/fakes.ts`), never stubbed +globals. + +## One-time setup (before the first deploy) + +1. **Create the GitHub OAuth App** — owner `UniverLab`, callback + `https://remote.univerlab.org/auth/callback`. It needs no scopes: + identity only. +2. **Bind the custom domain** — Workers & Pages → `canopy-remote` → + Settings → Domains & Routes → *Custom Domain* → + `remote.univerlab.org` (Cloudflare provisions the TLS certificate). +3. **Set the Worker secrets** — from `apps/remote/worker/`: + `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, + `SESSION_SECRET`, `TURN_KEY_ID` and `TURN_KEY_API_TOKEN` (the last two + from a Cloudflare Realtime TURN key). Never in the repo. +4. **Set the repository secret** — `CLOUDFLARE_API_TOKEN`, a token with + *Edit Workers*. The account id is inlined in `wrangler.toml`, so no + second secret is needed. + +## Deploy + +`.github/workflows/deploy-remote.yml`, **manual only** +(`workflow_dispatch`): in the trunk-based flow nothing deploys on merge. +To deploy by hand: + +```sh +cd apps/remote/worker +npx wrangler deploy --dry-run # check the bundle and the binding +npx wrangler deploy +``` diff --git a/apps/remote/worker/src/auth.ts b/apps/remote/worker/src/auth.ts new file mode 100644 index 0000000..c2e9741 --- /dev/null +++ b/apps/remote/worker/src/auth.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * GitHub OAuth for browsers: login redirect, code exchange, signed session. + * The GitHub token is discarded after `/user`; the browser keeps only the + * `cr_session` cookie, an HMAC-signed {uid, login, exp}. + */ +import { b64urlDecode, b64urlEncode, parseCookies, serializeCookie, timingSafeEqual } from "./codec.js"; +import { SESSION_COOKIE, STATE_COOKIE, applySecurityHeaders } from "./protocol.js"; +import { exchangeCode, fetchUser } from "./github.js"; + +/** The secrets the auth routes need; absent means 500, never a guess. */ +export interface AuthEnv { + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + SESSION_SECRET?: string; +} + +export interface Session { + uid: number; + login: string; +} + +/** Session lifetime: 12 hours, in seconds. */ +const SESSION_TTL_S = 43200; + +/** OAuth state lifetime: 10 minutes, in seconds. */ +const STATE_TTL_S = 600; + +async function hmacSha256(secret: string, message: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return new Uint8Array(signature); +} + +/** Sign {uid, login, exp} with SESSION_SECRET. `nowSec` is unix seconds. */ +export async function signSession( + uid: number, + login: string, + secret: string, + nowSec: number, +): Promise { + const payload = b64urlEncode( + new TextEncoder().encode(JSON.stringify({ uid, login, exp: nowSec + SESSION_TTL_S })), + ); + const signature = b64urlEncode(await hmacSha256(secret, payload)); + return `${payload}.${signature}`; +} + +/** Verify a session cookie, or null when it is forged, expired or malformed. */ +export async function verifySession( + value: string, + secret: string, + nowSec: number, +): Promise { + const parts = value.split("."); + if (parts.length !== 2) { + return null; + } + const [payload, signature] = parts as [string, string]; + const payloadBytes = b64urlDecode(payload); + const signatureBytes = b64urlDecode(signature); + if (payloadBytes === null || signatureBytes === null) { + return null; + } + const expected = await hmacSha256(secret, payload); + if (!timingSafeEqual(signatureBytes, expected)) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(payloadBytes)); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const { uid, login, exp } = parsed as Record; + if (typeof uid !== "number" || !Number.isInteger(uid)) { + return null; + } + if (typeof login !== "string" || login === "") { + return null; + } + if (typeof exp !== "number" || !(exp > nowSec)) { + return null; + } + return { uid, login }; +} + +/** Fresh OAuth state: 32 random bytes, base64url. */ +export function newState(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return b64urlEncode(bytes); +} + +function clearStateCookie(): string { + return serializeCookie(STATE_COOKIE, "", { + maxAge: 0, + path: "/auth", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); +} + +function json(body: unknown, status: number): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + return new Response(JSON.stringify(body), { status, headers }); +} + +function misconfigured(): Response { + return json({ error: "misconfigured" }, 500); +} + +function oauthFailed(): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + headers.append("set-cookie", clearStateCookie()); + return new Response(JSON.stringify({ error: "oauth_failed" }), { status: 400, headers }); +} + +/** + * Redirect to GitHub's authorize page (identity only: no scope) and park + * the state in a short-lived cookie. + */ +export function handleLogin(_req: Request, env: AuthEnv): Response { + const clientId = env.GITHUB_CLIENT_ID; + if (!clientId) { + return misconfigured(); + } + const state = newState(); + const location = + "https://github.com/login/oauth/authorize" + + `?client_id=${encodeURIComponent(clientId)}&state=${encodeURIComponent(state)}&allow_signup=false`; + const headers = applySecurityHeaders(new Headers({ location })); + headers.append( + "set-cookie", + serializeCookie(STATE_COOKIE, state, { + maxAge: STATE_TTL_S, + path: "/auth", + httpOnly: true, + secure: true, + sameSite: "Lax", + }), + ); + return new Response(null, { status: 302, headers }); +} + +/** Exchange the code, fetch the user, discard the token, set the session. */ +export async function handleCallback( + req: Request, + env: AuthEnv, + fetchImpl: typeof fetch = fetch, +): Promise { + const clientId = env.GITHUB_CLIENT_ID; + const clientSecret = env.GITHUB_CLIENT_SECRET; + const sessionSecret = env.SESSION_SECRET; + if (!clientId || !clientSecret || !sessionSecret) { + return misconfigured(); + } + const url = new URL(req.url); + const cookies = parseCookies(req.headers.get("cookie")); + const state = url.searchParams.get("state"); + const cookieState = cookies[STATE_COOKIE]; + if ( + url.searchParams.get("error") !== null || + url.searchParams.get("code") === null || + !state || + !cookieState || + state !== cookieState + ) { + return oauthFailed(); + } + const code = url.searchParams.get("code") as string; + const token = await exchangeCode(code, { GITHUB_CLIENT_ID: clientId, GITHUB_CLIENT_SECRET: clientSecret }, fetchImpl); + if (token === null) { + return oauthFailed(); + } + const user = await fetchUser(token, fetchImpl); + if (user === null) { + return oauthFailed(); + } + const session = await signSession(user.id, user.login, sessionSecret, Math.floor(Date.now() / 1000)); + const headers = applySecurityHeaders(new Headers({ location: "/" })); + headers.append("set-cookie", clearStateCookie()); + headers.append( + "set-cookie", + serializeCookie(SESSION_COOKIE, session, { + maxAge: SESSION_TTL_S, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Strict", + }), + ); + return new Response(null, { status: 302, headers }); +} + +/** Clear the session cookie. */ +export function handleLogout(_req: Request, _env: AuthEnv): Response { + void _req; + void _env; + const headers = applySecurityHeaders(new Headers({ location: "/" })); + headers.append( + "set-cookie", + serializeCookie(SESSION_COOKIE, "", { + maxAge: 0, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Strict", + }), + ); + return new Response(null, { status: 303, headers }); +} + +/** Who the session cookie says this browser is, or 401. */ +export async function handleMe(req: Request, env: AuthEnv): Promise { + const sessionSecret = env.SESSION_SECRET; + if (!sessionSecret) { + return misconfigured(); + } + const cookies = parseCookies(req.headers.get("cookie")); + const value = cookies[SESSION_COOKIE]; + if (!value) { + return json({ error: "unauthorized" }, 401); + } + const session = await verifySession(value, sessionSecret, Math.floor(Date.now() / 1000)); + if (session === null) { + return json({ error: "unauthorized" }, 401); + } + return json({ login: session.login }, 200); +} diff --git a/apps/remote/worker/src/codec.ts b/apps/remote/worker/src/codec.ts new file mode 100644 index 0000000..bc7b482 --- /dev/null +++ b/apps/remote/worker/src/codec.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Pure encoding helpers: base64url, constant-time comparison, cookies. + * Nothing here touches Request, Env or fetch, so it is trivially testable. + */ + +/** Unpadded base64url of `bytes`. */ +export function b64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +/** Decode unpadded base64url, or null when the input is malformed. */ +export function b64urlDecode(text: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]*$/.test(text)) { + return null; + } + const padded = text.replaceAll("-", "+").replaceAll("_", "/"); + const remainder = padded.length % 4; + if (remainder === 1) { + return null; + } + const withPadding = remainder === 0 ? padded : `${padded}${"=".repeat(4 - remainder)}`; + let binary: string; + try { + binary = atob(withPadding); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Constant-time byte comparison. Lengths are not secret here (both sides + * are signature-sized), so a length mismatch ends early. + */ +export function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false; + } + let diff = 0; + for (let i = 0; i < left.length; i += 1) { + diff |= left[i] as number ^ (right[i] as number); + } + return diff === 0; +} + +/** Parse a Cookie header into name/value pairs. First name wins. */ +export function parseCookies(header: string | null): Record { + const cookies: Record = {}; + if (!header) { + return cookies; + } + for (const part of header.split(";")) { + const trimmed = part.trim(); + if (trimmed === "") { + continue; + } + const separator = trimmed.indexOf("="); + if (separator <= 0) { + continue; + } + const name = trimmed.slice(0, separator).trim(); + const value = trimmed.slice(separator + 1).trim(); + if (!(name in cookies)) { + cookies[name] = value; + } + } + return cookies; +} + +export interface CookieOptions { + maxAge?: number; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Lax" | "Strict" | "None"; +} + +/** Build a Set-Cookie value. Values are the relay's own tokens, never quoted. */ +export function serializeCookie(name: string, value: string, options: CookieOptions): string { + const parts = [`${name}=${value}`]; + if (options.maxAge !== undefined) { + parts.push(`Max-Age=${options.maxAge}`); + } + if (options.path !== undefined) { + parts.push(`Path=${options.path}`); + } + if (options.httpOnly === true) { + parts.push("HttpOnly"); + } + if (options.secure === true) { + parts.push("Secure"); + } + if (options.sameSite !== undefined) { + parts.push(`SameSite=${options.sameSite}`); + } + return parts.join("; "); +} diff --git a/apps/remote/worker/src/github.ts b/apps/remote/worker/src/github.ts new file mode 100644 index 0000000..d7621bd --- /dev/null +++ b/apps/remote/worker/src/github.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The relay's only two GitHub calls: exchanging an OAuth code and proving + * a host bearer token belongs to a GitHub user. Host verifications are + * cached per isolate, keyed by the token's SHA-256 — the token itself is + * never stored. + */ +import { HOST_CACHE_TTL_S } from "./protocol.js"; + +/** A GitHub user, as far as the relay cares. */ +export interface GitHubUser { + id: number; + login: string; +} + +/** The OAuth app credentials the code exchange needs. */ +export interface GitHubOAuthEnv { + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; +} + +interface CacheEntry { + user: GitHubUser; + expiresAt: number; +} + +/** Verified host tokens, keyed by SHA-256 hex of the token. Isolate memory only. */ +const hostCache = new Map(); + +/** Test-only: drop every cached verification. */ +export function __clearHostCache(): void { + hostCache.clear(); +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Exchange an OAuth code for a token, or null when GitHub refuses. */ +export async function exchangeCode( + code: string, + env: GitHubOAuthEnv, + fetchImpl: typeof fetch = fetch, +): Promise { + let response: Response; + try { + response = await fetchImpl("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + }), + }); + } catch { + return null; + } + if (!response.ok) { + return null; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return null; + } + const token = (parsed as Record)?.["access_token"]; + return typeof token === "string" && token !== "" ? token : null; +} + +/** Who a GitHub token belongs to, or null on any non-200. */ +export async function fetchUser( + token: string, + fetchImpl: typeof fetch = fetch, +): Promise { + let response: Response; + try { + response = await fetchImpl("https://api.github.com/user", { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "canopy-remote", + }, + }); + } catch { + return null; + } + if (!response.ok) { + return null; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return null; + } + const record = parsed as Record; + if (typeof record?.["id"] !== "number" || typeof record?.["login"] !== "string") { + return null; + } + return { id: record["id"] as number, login: record["login"] as string }; +} + +/** + * Verify a host bearer token, caching the answer 5 minutes by SHA-256 of + * the token. Failures are never cached. + */ +export async function verifyHostToken( + token: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const key = await sha256Hex(token); + const now = Date.now(); + const cached = hostCache.get(key); + if (cached !== undefined && cached.expiresAt > now) { + return cached.user; + } + if (cached !== undefined) { + hostCache.delete(key); + } + const user = await fetchUser(token, fetchImpl); + if (user === null) { + return null; + } + if (hostCache.size > 1000) { + for (const [entryKey, entry] of hostCache) { + if (entry.expiresAt <= now) { + hostCache.delete(entryKey); + } + } + } + hostCache.set(key, { user, expiresAt: now + HOST_CACHE_TTL_S * 1000 }); + return user; +} diff --git a/apps/remote/worker/src/index.ts b/apps/remote/worker/src/index.ts new file mode 100644 index 0000000..df5140f --- /dev/null +++ b/apps/remote/worker/src/index.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * remote.univerlab.org — the blind signaling relay: GitHub auth, one room + * per user, TURN credentials. Every route either proves an identity or + * forwards an opaque envelope; any other path is a 404 for now. + */ +import { handleCallback, handleLogin, handleLogout, handleMe, type AuthEnv } from "./auth.js"; +import { handleDeviceWs, handleHostWs, type RoomNamespace } from "./ws.js"; +import { handleTurn, type TurnEnv } from "./turn.js"; +import { applySecurityHeaders } from "./protocol.js"; + +export { UserRoom } from "./room.js"; + +/** The Worker's environment: one room binding plus the five secrets. */ +export interface Env extends AuthEnv, TurnEnv { + USER_ROOM: RoomNamespace; +} + +function json(body: unknown, status: number): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + return new Response(JSON.stringify(body), { status, headers }); +} + +/** The method each defined path answers; anything else is a 405. */ +const ROUTE_METHODS: Record = { + "/auth/login": "GET", + "/auth/callback": "GET", + "/auth/logout": "POST", + "/auth/me": "GET", + "/ws/device": "GET", + "/ws/host": "GET", + "/turn": "GET", +}; + +export default { + fetch(req: Request, env: Env): Response | Promise { + const pathname = new URL(req.url).pathname; + const method = ROUTE_METHODS[pathname]; + if (method === undefined) { + return json({ error: "not_found" }, 404); + } + if (req.method !== method) { + return json({ error: "method_not_allowed" }, 405); + } + switch (pathname) { + case "/auth/login": + return handleLogin(req, env); + case "/auth/callback": + return handleCallback(req, env); + case "/auth/logout": + return handleLogout(req, env); + case "/auth/me": + return handleMe(req, env); + case "/ws/device": + return handleDeviceWs(req, env); + case "/ws/host": + return handleHostWs(req, env); + case "/turn": + return handleTurn(req, env); + default: + return json({ error: "not_found" }, 404); + } + }, +}; diff --git a/apps/remote/worker/src/protocol.ts b/apps/remote/worker/src/protocol.ts new file mode 100644 index 0000000..d4568a9 --- /dev/null +++ b/apps/remote/worker/src/protocol.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Every literal shared by two or more modules lives here, so the relay's + * wire details have exactly one home. If the same string appears in any + * other module, it belongs here instead. + */ + +/** Signed session cookie set by the OAuth callback. */ +export const SESSION_COOKIE = "cr_session"; + +/** Short-lived OAuth state cookie, cleared by the callback. */ +export const STATE_COOKIE = "cr_oauth_state"; + +/** Internal headers the Worker sets when forwarding to the room. */ +export const RELAY_ROLE_HEADER = "X-Relay-Role"; +export const RELAY_HOST_ID_HEADER = "X-Relay-Host-Id"; +export const RELAY_LABEL_HEADER = "X-Relay-Label"; + +/** Largest accepted WebSocket frame, in bytes. */ +export const FRAME_MAX_BYTES = 65536; + +/** Most device sockets a room holds; the next one is refused. */ +export const MAX_DEVICES = 8; + +/** Most `pair_init` bodies a room accepts per window. */ +export const PAIR_INIT_MAX = 20; + +/** The `pair_init` window, in milliseconds. */ +export const PAIR_INIT_WINDOW_MS = 600000; + +/** How long a verified host token stays cached, in seconds. */ +export const HOST_CACHE_TTL_S = 300; + +/** Error codes the relay itself sends. */ +export type RelayErrorCode = "no_target" | "too_large" | "rate_limited" | "bad_frame"; + +/** An envelope a client sends: a target plus an opaque body. */ +export interface IncomingFrame { + to: string; + body: unknown; +} + +/** An envelope the room delivers: a source plus the untouched body. */ +export interface OutgoingFrame { + from: string; + body: unknown; +} + +/** The per-socket attachment; runtime state lives only here. */ +export interface RoomAttachment { + role: "device" | "host"; + host_id?: string; + label?: string; + conn: string; +} + +/** One host entry in the `hosts` notice. */ +export interface HostItem { + host_id: string; + label: string; +} + +/** The security headers every response carries. */ +export const SECURITY_HEADERS: Record = { + "content-security-policy": + "default-src 'self'; connect-src 'self' wss://remote.univerlab.org; " + + "img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", +}; + +/** Set the security headers on `headers` and return it. */ +export function applySecurityHeaders(headers: Headers): Headers { + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + headers.set(name, value); + } + return headers; +} diff --git a/apps/remote/worker/src/room.ts b/apps/remote/worker/src/room.ts new file mode 100644 index 0000000..f27069f --- /dev/null +++ b/apps/remote/worker/src/room.ts @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * One UserRoom per GitHub user: it forwards opaque signaling envelopes + * between that user's devices and hosts and remembers nothing past the + * sockets. State lives only in per-socket attachments plus two instance + * fields; when the last socket closes, nothing of the user remains. + */ +import { + FRAME_MAX_BYTES, + MAX_DEVICES, + PAIR_INIT_MAX, + PAIR_INIT_WINDOW_MS, + RELAY_HOST_ID_HEADER, + RELAY_LABEL_HEADER, + RELAY_ROLE_HEADER, + applySecurityHeaders, + type HostItem, + type RelayErrorCode, + type RoomAttachment, +} from "./protocol.js"; + +/** A hibernating socket, narrowed to what the room uses. */ +export interface RoomSocket { + accept(): void; + serializeAttachment(attachment: RoomAttachment): void; + deserializeAttachment(): RoomAttachment | null; + send(message: string): void; + close(code?: number, reason?: string): void; +} + +/** The hibernation state, narrowed to what the room uses. */ +export interface RoomState { + acceptWebSocket(socket: RoomSocket): void; + getWebSockets(): RoomSocket[]; +} + +/** A freshly split WebSocket pair: index 0 is the client side. */ +interface WebSocketPairLike { + 0: RoomSocket; + 1: RoomSocket; +} + +/** The runtime's pair constructor, declared narrowly for this module. */ +declare const WebSocketPair: new () => WebSocketPairLike; + +/** A 101 carrying `client`, with the relay's security headers. */ +function upgradeResponse(client: RoomSocket): Response { + const headers = applySecurityHeaders(new Headers()); + const init: ResponseInit & { webSocket: RoomSocket } = { status: 101, headers, webSocket: client }; + return new Response(null, init); +} + +function json(body: unknown, status: number): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + return new Response(JSON.stringify(body), { status, headers }); +} + +function errorFrame(code: RelayErrorCode): string { + return JSON.stringify({ from: "relay", body: { t: "error", code } }); +} + +function hostsFrame(items: HostItem[]): string { + return JSON.stringify({ from: "relay", body: { t: "hosts", items } }); +} + +export class UserRoom { + nextConn = 1; + pairInit: number[] = []; + + constructor(private readonly state: RoomState) {} + + fetch(request: Request): Response { + if ((request.headers.get("upgrade") ?? "").toLowerCase() !== "websocket") { + return json({ error: "upgrade_required" }, 426); + } + const role = request.headers.get(RELAY_ROLE_HEADER); + if (role !== "device" && role !== "host") { + return json({ error: "bad_request" }, 400); + } + const hostId = request.headers.get(RELAY_HOST_ID_HEADER); + if (role === "host" && hostId === null) { + return json({ error: "bad_request" }, 400); + } + if (role === "device" && this.deviceCount() >= MAX_DEVICES) { + const pair = new WebSocketPair(); + // The runtime only delivers the close on an accepted socket; the + // socket still never joins the room. + pair[1].accept(); + pair[1].close(4008, "too_many_devices"); + return upgradeResponse(pair[0]); + } + if (role === "host") { + this.closeHost(hostId as string); + } + const pair = new WebSocketPair(); + const server = pair[1]; + this.state.acceptWebSocket(server); + const attachment: RoomAttachment = { + role, + conn: String(this.nextConn), + }; + this.nextConn += 1; + if (role === "host") { + attachment.host_id = hostId as string; + attachment.label = request.headers.get(RELAY_LABEL_HEADER) ?? ""; + } + server.serializeAttachment(attachment); + if (role === "device") { + server.send(hostsFrame(this.hostItems())); + } else { + this.broadcastHosts(); + } + return upgradeResponse(pair[0]); + } + + webSocketMessage(socket: RoomSocket, message: string | ArrayBuffer): void { + if (typeof message !== "string") { + socket.send(errorFrame("bad_frame")); + return; + } + if (new TextEncoder().encode(message).length > FRAME_MAX_BYTES) { + socket.send(errorFrame("too_large")); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + socket.send(errorFrame("bad_frame")); + return; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + socket.send(errorFrame("bad_frame")); + return; + } + const { to, body } = parsed as { to?: unknown; body?: unknown }; + if (typeof to !== "string") { + socket.send(errorFrame("bad_frame")); + return; + } + if (this.isPairInit(body)) { + const now = Date.now(); + this.pairInit = this.pairInit.filter((stamp) => now - stamp < PAIR_INIT_WINDOW_MS); + if (this.pairInit.length >= PAIR_INIT_MAX) { + socket.send(errorFrame("rate_limited")); + return; + } + this.pairInit.push(now); + } + this.route(socket, to, body); + } + + webSocketClose(socket: RoomSocket): void { + if (socket.deserializeAttachment()?.role === "host") { + this.broadcastHosts(); + } + } + + webSocketError(socket: RoomSocket): void { + if (socket.deserializeAttachment()?.role === "host") { + this.broadcastHosts(); + } + } + + /** True when the body is an object whose `t` is `pair_init`. */ + private isPairInit(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + (body as Record)["t"] === "pair_init" + ); + } + + /** How many device sockets are currently attached. */ + private deviceCount(): number { + let count = 0; + for (const socket of this.state.getWebSockets()) { + if (socket.deserializeAttachment()?.role === "device") { + count += 1; + } + } + return count; + } + + /** Close the attached socket holding `hostId`, if any. */ + private closeHost(hostId: string): void { + for (const socket of this.state.getWebSockets()) { + const attachment = socket.deserializeAttachment(); + if (attachment?.role === "host" && attachment.host_id === hostId) { + socket.close(4000, "replaced"); + } + } + } + + /** The current hosts, sorted by id. */ + private hostItems(): HostItem[] { + const items: HostItem[] = []; + for (const socket of this.state.getWebSockets()) { + const attachment = socket.deserializeAttachment(); + if (attachment?.role === "host" && attachment.host_id !== undefined) { + items.push({ host_id: attachment.host_id, label: attachment.label ?? "" }); + } + } + items.sort((left, right) => (left.host_id < right.host_id ? -1 : left.host_id > right.host_id ? 1 : 0)); + return items; + } + + /** Push the hosts list to every attached device. */ + private broadcastHosts(): void { + const frame = hostsFrame(this.hostItems()); + for (const socket of this.state.getWebSockets()) { + if (socket.deserializeAttachment()?.role === "device") { + socket.send(frame); + } + } + } + + /** Deliver one envelope, rewriting its source; unknown targets bounce. */ + private route(socket: RoomSocket, to: string, body: unknown): void { + const sender = socket.deserializeAttachment(); + if (sender === null) { + socket.send(errorFrame("no_target")); + return; + } + if (sender.role === "device" && to.startsWith("h:")) { + const target = this.findHost(to.slice(2)); + if (target !== null) { + target.send(JSON.stringify({ from: `d:${sender.conn}`, body })); + return; + } + } else if (sender.role === "host" && to.startsWith("d:")) { + const target = this.findDevice(to.slice(2)); + if (target !== null && sender.host_id !== undefined) { + target.send(JSON.stringify({ from: `h:${sender.host_id}`, body })); + return; + } + } + socket.send(errorFrame("no_target")); + } + + /** The attached host socket holding `hostId`, if any. */ + private findHost(hostId: string): RoomSocket | null { + for (const socket of this.state.getWebSockets()) { + const attachment = socket.deserializeAttachment(); + if (attachment?.role === "host" && attachment.host_id === hostId) { + return socket; + } + } + return null; + } + + /** The attached device socket holding `conn`, if any. */ + private findDevice(conn: string): RoomSocket | null { + for (const socket of this.state.getWebSockets()) { + const attachment = socket.deserializeAttachment(); + if (attachment?.role === "device" && attachment.conn === conn) { + return socket; + } + } + return null; + } +} diff --git a/apps/remote/worker/src/turn.ts b/apps/remote/worker/src/turn.ts new file mode 100644 index 0000000..8e6434f --- /dev/null +++ b/apps/remote/worker/src/turn.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Short-lived TURN credentials for both ends of a call. Either credential + * the relay accepts (session cookie or host bearer) unlocks it; the + * credentials come from Cloudflare's TURN API and are never stored. + */ +import { parseCookies } from "./codec.js"; +import { verifySession } from "./auth.js"; +import { verifyHostToken } from "./github.js"; +import { SESSION_COOKIE, applySecurityHeaders } from "./protocol.js"; + +/** The secrets the TURN route needs; absent means 500, never a guess. */ +export interface TurnEnv { + SESSION_SECRET?: string; + TURN_KEY_ID?: string; + TURN_KEY_API_TOKEN?: string; +} + +/** Credential lifetime requested from Cloudflare, in seconds. */ +const TURN_TTL_S = 3600; + +const BEARER_PATTERN = /^Bearer (.+)$/; + +function json(body: unknown, status: number): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + return new Response(JSON.stringify(body), { status, headers }); +} + +/** Either a valid session cookie or a verified host bearer unlocks TURN. */ +async function authorized( + req: Request, + env: TurnEnv, + fetchImpl: typeof fetch, +): Promise { + const sessionSecret = env.SESSION_SECRET; + if (sessionSecret) { + const cookies = parseCookies(req.headers.get("cookie")); + const value = cookies[SESSION_COOKIE]; + if (value) { + const session = await verifySession(value, sessionSecret, Math.floor(Date.now() / 1000)); + if (session !== null) { + return true; + } + } + } + const authorization = req.headers.get("authorization"); + const bearer = authorization === null ? null : BEARER_PATTERN.exec(authorization); + if (bearer === null) { + return false; + } + return (await verifyHostToken(bearer[1] as string, fetchImpl)) !== null; +} + +/** Mint TURN credentials: upstream entries plus Cloudflare STUN. */ +export async function handleTurn( + req: Request, + env: TurnEnv, + fetchImpl: typeof fetch = fetch, +): Promise { + const keyId = env.TURN_KEY_ID; + const apiToken = env.TURN_KEY_API_TOKEN; + if (!env.SESSION_SECRET || !keyId || !apiToken) { + return json({ error: "misconfigured" }, 500); + } + if (!(await authorized(req, env, fetchImpl))) { + return json({ error: "unauthorized" }, 401); + } + let response: Response; + try { + response = await fetchImpl( + `https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(keyId)}/credentials/generate`, + { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ttl: TURN_TTL_S }), + }, + ); + } catch { + return json({ error: "turn_unavailable" }, 502); + } + if (!response.ok) { + return json({ error: "turn_unavailable" }, 502); + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return json({ error: "turn_unavailable" }, 502); + } + const upstream = (parsed as Record)?.["iceServers"]; + if (!Array.isArray(upstream)) { + return json({ error: "turn_unavailable" }, 502); + } + return json( + { iceServers: [...upstream, { urls: "stun:stun.cloudflare.com:3478" }] }, + 200, + ); +} diff --git a/apps/remote/worker/src/ws.ts b/apps/remote/worker/src/ws.ts new file mode 100644 index 0000000..b782953 --- /dev/null +++ b/apps/remote/worker/src/ws.ts @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The two WebSocket doors: browsers prove the session cookie, hosts prove + * a GitHub token plus their id and label. Both land in the caller's own + * UserRoom; the room itself only trusts the X-Relay-* headers set here. + */ +import { parseCookies } from "./codec.js"; +import { verifySession } from "./auth.js"; +import { verifyHostToken } from "./github.js"; +import { + RELAY_HOST_ID_HEADER, + RELAY_LABEL_HEADER, + RELAY_ROLE_HEADER, + SESSION_COOKIE, + applySecurityHeaders, +} from "./protocol.js"; + +/** A room stub, narrowed to the one call the Worker makes. */ +export interface RoomStub { + fetch(request: Request): Promise; +} + +/** The UserRoom binding, narrowed to naming a room by user id. */ +export interface RoomNamespace { + idFromName(name: string): unknown; + get(id: unknown): RoomStub; +} + +/** What the WebSocket doors need from the environment. */ +export interface WsEnv { + USER_ROOM: RoomNamespace; + SESSION_SECRET?: string; +} + +const BEARER_PATTERN = /^Bearer (.+)$/; +const HOST_ID_PATTERN = /^[0-9a-f]{16}$/; + +function json(body: unknown, status: number): Response { + const headers = applySecurityHeaders( + new Headers({ "content-type": "application/json; charset=utf-8" }), + ); + return new Response(JSON.stringify(body), { status, headers }); +} + +/** + * Forward the upgrade to `room` with the relay headers set and the + * caller's credentials stripped. + */ +function forward(req: Request, room: RoomStub, role: string, hostId?: string, label?: string): Promise { + const headers = new Headers(req.headers); + headers.delete("cookie"); + headers.delete("authorization"); + headers.set(RELAY_ROLE_HEADER, role); + if (hostId !== undefined) { + headers.set(RELAY_HOST_ID_HEADER, hostId); + } + if (label !== undefined) { + headers.set(RELAY_LABEL_HEADER, label); + } + return room.fetch(new Request("https://user-room/", { headers })); +} + +function roomFor(env: WsEnv, uid: number): RoomStub { + return env.USER_ROOM.get(env.USER_ROOM.idFromName(String(uid))); +} + +/** A browser socket: the session cookie names its room. */ +export async function handleDeviceWs(req: Request, env: WsEnv): Promise { + const sessionSecret = env.SESSION_SECRET; + if (!sessionSecret) { + return json({ error: "misconfigured" }, 500); + } + const cookies = parseCookies(req.headers.get("cookie")); + const value = cookies[SESSION_COOKIE]; + if (!value) { + return json({ error: "unauthorized" }, 401); + } + const session = await verifySession(value, sessionSecret, Math.floor(Date.now() / 1000)); + if (session === null) { + return json({ error: "unauthorized" }, 401); + } + return forward(req, roomFor(env, session.uid), "device"); +} + +/** A host socket: GitHub token plus id and label, verified then forwarded. */ +export async function handleHostWs( + req: Request, + env: WsEnv, + fetchImpl: typeof fetch = fetch, +): Promise { + const authorization = req.headers.get("authorization"); + const bearer = authorization === null ? null : BEARER_PATTERN.exec(authorization); + if (bearer === null) { + return json({ error: "unauthorized" }, 401); + } + const hostId = req.headers.get("x-canopy-host-id"); + if (hostId === null || !HOST_ID_PATTERN.test(hostId)) { + return json({ error: "bad_host_id" }, 400); + } + const label = req.headers.get("x-canopy-host-label"); + if (label === null || label.length < 1 || label.length > 64) { + return json({ error: "bad_label" }, 400); + } + const user = await verifyHostToken(bearer[1] as string, fetchImpl); + if (user === null) { + return json({ error: "unauthorized" }, 401); + } + return forward(req, roomFor(env, user.id), "host", hostId, label); +} diff --git a/apps/remote/worker/test/auth.test.ts b/apps/remote/worker/test/auth.test.ts new file mode 100644 index 0000000..f5a1992 --- /dev/null +++ b/apps/remote/worker/test/auth.test.ts @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { + handleCallback, + handleLogin, + handleLogout, + handleMe, + signSession, + verifySession, + type AuthEnv, +} from "../src/auth.js"; +import { fakeFetch } from "./fakes.js"; + +const ORIGIN = "https://remote.univerlab.org"; +const SECRET = "test-session-secret-that-is-long-enough"; +const NOW = 1793100000; + +const env: AuthEnv = { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + SESSION_SECRET: SECRET, +}; + +function request(path: string, cookie?: string): Request { + const headers = new Headers(); + if (cookie !== undefined) { + headers.set("cookie", cookie); + } + return new Request(new URL(path, ORIGIN), { headers }); +} + +/** The value of the named cookie in the response's Set-Cookie headers. */ +function setCookie(response: Response, name: string): string | null { + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0]?.trim() ?? ""; + if (pair.startsWith(`${name}=`)) { + return pair.slice(name.length + 1); + } + } + return null; +} + +function hasAttribute(response: Response, name: string, attribute: string): boolean { + return response.headers + .getSetCookie() + .some((header) => header.startsWith(`${name}=`) && header.includes(attribute)); +} + +describe("session cookies", () => { + it("round-trips a signed session", async () => { + const value = await signSession(123, "octo", SECRET, NOW); + expect(await verifySession(value, SECRET, NOW + 60)).toEqual({ uid: 123, login: "octo" }); + }); + + it("rejects a tampered payload", async () => { + const value = await signSession(123, "octo", SECRET, NOW); + const [payload, signature] = value.split(".") as [string, string]; + const tampered = `${payload?.slice(0, -2)}xx.${signature}`; + expect(await verifySession(tampered, SECRET, NOW + 60)).toBeNull(); + }); + + it("rejects a tampered signature", async () => { + const value = await signSession(123, "octo", SECRET, NOW); + const [payload, signature] = value.split(".") as [string, string]; + const tampered = `${payload}.${signature?.slice(0, -2)}xx`; + expect(await verifySession(tampered, SECRET, NOW + 60)).toBeNull(); + }); + + it("rejects an expired session", async () => { + const value = await signSession(123, "octo", SECRET, NOW); + expect(await verifySession(value, SECRET, NOW + 43201)).toBeNull(); + }); + + it("rejects a session signed with another secret", async () => { + const value = await signSession(123, "octo", "another-secret", NOW); + expect(await verifySession(value, SECRET, NOW + 60)).toBeNull(); + }); + + it("rejects malformed values", async () => { + expect(await verifySession("no-separator", SECRET, NOW)).toBeNull(); + expect(await verifySession("a.b.c", SECRET, NOW)).toBeNull(); + expect(await verifySession(".", SECRET, NOW)).toBeNull(); + }); +}); + +describe("GET /auth/login", () => { + it("redirects to GitHub with the client id, a state and no scope", () => { + const response = handleLogin(request("/auth/login"), env); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("location") as string); + expect(location.origin).toBe("https://github.com"); + expect(location.pathname).toBe("/login/oauth/authorize"); + expect(location.searchParams.get("client_id")).toBe("test-client-id"); + expect(location.searchParams.get("allow_signup")).toBe("false"); + expect(location.searchParams.get("scope")).toBeNull(); + const state = location.searchParams.get("state"); + expect(state).toBeTruthy(); + expect(setCookie(response, "cr_oauth_state")).toBe(state); + }); + + it("parks the state in a short-lived cookie", () => { + const response = handleLogin(request("/auth/login"), env); + expect(hasAttribute(response, "cr_oauth_state", "Max-Age=600")).toBe(true); + expect(hasAttribute(response, "cr_oauth_state", "Path=/auth")).toBe(true); + expect(hasAttribute(response, "cr_oauth_state", "HttpOnly")).toBe(true); + expect(hasAttribute(response, "cr_oauth_state", "Secure")).toBe(true); + expect(hasAttribute(response, "cr_oauth_state", "SameSite=Lax")).toBe(true); + }); + + it("answers 500 without the client id", () => { + const response = handleLogin(request("/auth/login"), { SESSION_SECRET: SECRET }); + expect(response.status).toBe(500); + }); +}); + +describe("GET /auth/callback", () => { + const github = () => + fakeFetch([ + { url: "login/oauth/access_token", status: 200, body: { access_token: "gho_token" } }, + { url: "api.github.com/user", status: 200, body: { id: 123, login: "octo" } }, + ]); + + it("sets the session and clears the state on success", async () => { + const fake = github(); + const response = await handleCallback( + request("/auth/callback?code=abc&state=state-1", "cr_oauth_state=state-1"), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(fake.missing).toEqual([]); + const session = setCookie(response, "cr_session"); + expect(session).toBeTruthy(); + expect(await verifySession(session as string, SECRET, Math.floor(Date.now() / 1000))).toEqual({ + uid: 123, + login: "octo", + }); + expect(hasAttribute(response, "cr_session", "Max-Age=43200")).toBe(true); + expect(hasAttribute(response, "cr_session", "Path=/")).toBe(true); + expect(hasAttribute(response, "cr_session", "HttpOnly")).toBe(true); + expect(hasAttribute(response, "cr_session", "Secure")).toBe(true); + expect(hasAttribute(response, "cr_session", "SameSite=Strict")).toBe(true); + expect(hasAttribute(response, "cr_oauth_state", "Max-Age=0")).toBe(true); + }); + + it("rejects a state mismatch and clears the state cookie", async () => { + const fake = github(); + const response = await handleCallback( + request("/auth/callback?code=abc&state=state-1", "cr_oauth_state=other"), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "oauth_failed" }); + expect(hasAttribute(response, "cr_oauth_state", "Max-Age=0")).toBe(true); + expect(setCookie(response, "cr_session")).toBeNull(); + expect(fake.calls).toEqual([]); + }); + + it("rejects a missing state, code or an error query", async () => { + const fake = github(); + for (const path of [ + "/auth/callback?code=abc", + "/auth/callback?state=state-1", + "/auth/callback?code=abc&state=state-1&error=access_denied", + ]) { + const response = await handleCallback( + request(path, "cr_oauth_state=state-1"), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(400); + } + expect(fake.calls).toEqual([]); + }); + + it("rejects a refused code exchange", async () => { + const fake = fakeFetch([ + { url: "login/oauth/access_token", status: 400, body: { error: "bad_verification_code" } }, + ]); + const response = await handleCallback( + request("/auth/callback?code=bad&state=state-1", "cr_oauth_state=state-1"), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "oauth_failed" }); + }); +}); + +describe("POST /auth/logout", () => { + it("clears the session cookie", () => { + const response = handleLogout(request("/auth/logout", "cr_session=whatever"), env); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("/"); + expect(setCookie(response, "cr_session")).toBe(""); + expect(hasAttribute(response, "cr_session", "Max-Age=0")).toBe(true); + }); +}); + +describe("GET /auth/me", () => { + it("returns the login for a valid session", async () => { + const session = await signSession(123, "octo", SECRET, Math.floor(Date.now() / 1000)); + const response = await handleMe(request("/auth/me", `cr_session=${session}`), env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ login: "octo" }); + }); + + it("answers 401 without a session or with a forged one", async () => { + expect((await handleMe(request("/auth/me"), env)).status).toBe(401); + const forged = await signSession(123, "octo", "wrong-secret", Math.floor(Date.now() / 1000)); + const response = await handleMe(request("/auth/me", `cr_session=${forged}`), env); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + }); +}); diff --git a/apps/remote/worker/test/fakes.ts b/apps/remote/worker/test/fakes.ts new file mode 100644 index 0000000..023583b --- /dev/null +++ b/apps/remote/worker/test/fakes.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Fakes the worker tests inject instead of stubbing globals. + * + * fakeFetch answers from a route table and records every URL it was asked + * for. FakeRoomState is a Map-free hibernation stand-in whose `storage` + * getter throws, so any accidental storage touch fails the test that + * made it. Closing a fake socket drops it from its state, mirroring the + * runtime, which removes a closed socket before calling webSocketClose. + */ +import type { RoomAttachment } from "../src/protocol.js"; +import type { RoomSocket, RoomState } from "../src/room.js"; + +/** One stubbed upstream answer. `body` is JSON-encoded, `text` stays raw. */ +export interface Stub { + /** Matched against the full request URL: exact string or part of it. */ + url: string; + status: number; + body?: unknown; + text?: string; + headers?: Record; +} + +export interface FakeFetch { + fetchImpl: typeof fetch; + calls: string[]; + /** The raw body sent with each call, in the same order as `calls`. */ + bodies: string[]; + /** Intercept paths explicitly NOT stubbed (a miss). */ + missing: string[]; +} + +/** Build a substitutable fetch over a route table, longest URL first. */ +export function fakeFetch(stubs: Stub[]): FakeFetch { + const ordered = [...stubs].sort((a, b) => b.url.length - a.url.length); + const calls: string[] = []; + const bodies: string[] = []; + const missing: string[] = []; + const fetchImpl = (async (input: URL | Request | string, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + bodies.push(typeof init?.body === "string" ? init.body : ""); + for (const stub of ordered) { + if (!url.includes(stub.url)) { + continue; + } + const headers = new Headers({ "content-type": "application/json", ...stub.headers }); + const payload = stub.text ?? (stub.body === undefined ? "" : JSON.stringify(stub.body)); + return new Response(payload, { + status: stub.status, + headers, + }); + } + missing.push(url); + return new Response("{}", { status: 404, headers: new Headers() }); + }) as unknown as typeof fetch; + return { fetchImpl, calls, bodies, missing }; +} + +/** A socket the tests drive by hand: every send and close is recorded. */ +export class FakeRoomSocket implements RoomSocket { + attachment: RoomAttachment | null = null; + sent: string[] = []; + closed: Array<{ code?: number; reason?: string }> = []; + + constructor(private readonly state?: FakeRoomState) {} + + accept(): void {} + + serializeAttachment(attachment: RoomAttachment): void { + this.attachment = attachment; + } + + deserializeAttachment(): RoomAttachment | null { + return this.attachment; + } + + send(message: string): void { + this.sent.push(message); + } + + close(code?: number, reason?: string): void { + this.closed.push({ code, reason }); + this.state?.remove(this); + } + + /** The parsed bodies of everything sent to this socket. */ + frames(): Array<{ from: string; body: unknown }> { + return this.sent.map((text) => JSON.parse(text) as { from: string; body: unknown }); + } +} + +/** Hibernation state for direct room tests; storage access throws. */ +export class FakeRoomState implements RoomState { + private sockets: RoomSocket[] = []; + + get storage(): never { + throw new Error("UserRoom must never touch storage"); + } + + acceptWebSocket(socket: RoomSocket): void { + this.sockets.push(socket); + // Real pair sockets can only send once accepted; the runtime accepts + // them inside the real state, so the fake does it here. Fakes no-op. + socket.accept(); + } + + getWebSockets(): RoomSocket[] { + return [...this.sockets]; + } + + /** Drop a closed socket, like the runtime does. */ + remove(socket: RoomSocket): void { + this.sockets = this.sockets.filter((candidate) => candidate !== socket); + } + + /** A fresh fake socket bound to this state. */ + makeSocket(): FakeRoomSocket { + return new FakeRoomSocket(this); + } +} diff --git a/apps/remote/worker/test/headers.test.ts b/apps/remote/worker/test/headers.test.ts new file mode 100644 index 0000000..285d0ce --- /dev/null +++ b/apps/remote/worker/test/headers.test.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import worker, { type Env } from "../src/index.js"; +import { UserRoom } from "../src/room.js"; +import type { RoomNamespace, RoomStub } from "../src/ws.js"; +import { FakeRoomState } from "./fakes.js"; + +const ORIGIN = "https://remote.univerlab.org"; + +const EXPECTED_CSP = + "default-src 'self'; connect-src 'self' wss://remote.univerlab.org; " + + "img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'"; + +class FakeNamespace implements RoomNamespace { + idFromName(name: string): unknown { + void name; + return "room"; + } + + get(id: unknown): RoomStub { + void id; + return { + fetch: (): Promise => Promise.resolve(new Response("forwarded")), + }; + } +} + +const env: Env = { + USER_ROOM: new FakeNamespace(), + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + SESSION_SECRET: "test-session-secret-that-is-long-enough", + TURN_KEY_ID: "turn-key-id", + TURN_KEY_API_TOKEN: "turn-api-token", +}; + +function request(path: string, options: { method?: string; cookie?: string } = {}): Request { + const headers = new Headers(); + if (options.cookie !== undefined) { + headers.set("cookie", options.cookie); + } + return new Request(new URL(path, ORIGIN), { method: options.method ?? "GET", headers }); +} + +function expectSecurityHeaders(response: Response): void { + expect(response.headers.get("content-security-policy")).toBe(EXPECTED_CSP); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); +} + +describe("security headers", () => { + const cases: Array<{ name: string; path: string; method?: string; cookie?: string; status: number }> = [ + { name: "login 302", path: "/auth/login", status: 302 }, + { name: "callback 400", path: "/auth/callback?code=x&state=y", cookie: "cr_oauth_state=z", status: 400 }, + { name: "me 401", path: "/auth/me", status: 401 }, + { name: "logout 303", path: "/auth/logout", method: "POST", status: 303 }, + { name: "device 401", path: "/ws/device", status: 401 }, + { name: "host 401", path: "/ws/host", status: 401 }, + { name: "turn 401", path: "/turn", status: 401 }, + { name: "unknown path 404", path: "/nope", status: 404 }, + { name: "wrong method 405", path: "/auth/me", method: "POST", status: 405 }, + ]; + + for (const { name, path, method, cookie, status } of cases) { + it(`carries the headers on ${name}`, async () => { + const response = await worker.fetch(request(path, { method, cookie }), env); + expect(response.status).toBe(status); + expectSecurityHeaders(response); + }); + } + + it("carries the headers on the room's 426 and 400", () => { + const room = new UserRoom(new FakeRoomState()); + const upgrade = new Request("https://user-room/", { + headers: { upgrade: "websocket", "x-relay-role": "browser" }, + }); + for (const response of [room.fetch(new Request("https://user-room/")), room.fetch(upgrade)]) { + expectSecurityHeaders(response); + } + expect(room.fetch(new Request("https://user-room/")).status).toBe(426); + expect(room.fetch(upgrade).status).toBe(400); + }); +}); diff --git a/apps/remote/worker/test/host.test.ts b/apps/remote/worker/test/host.test.ts new file mode 100644 index 0000000..82fe593 --- /dev/null +++ b/apps/remote/worker/test/host.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +import { beforeEach, describe, expect, it } from "vitest"; +import { signSession } from "../src/auth.js"; +import { __clearHostCache } from "../src/github.js"; +import { handleDeviceWs, handleHostWs, type RoomNamespace, type RoomStub, type WsEnv } from "../src/ws.js"; +import { fakeFetch } from "./fakes.js"; + +const ORIGIN = "https://remote.univerlab.org"; +const SECRET = "test-session-secret-that-is-long-enough"; +const HOST_ID = "0123456789abcdef"; + +/** A room binding that records which room each socket was sent to. */ +class FakeNamespace implements RoomNamespace { + names: string[] = []; + forwarded: Request[] = []; + + idFromName(name: string): unknown { + this.names.push(name); + return name; + } + + get(id: unknown): RoomStub { + void id; + return { + fetch: (request: Request): Promise => { + this.forwarded.push(request); + return Promise.resolve(new Response("forwarded")); + }, + }; + } +} + +function setup(): { namespace: FakeNamespace; env: WsEnv } { + const namespace = new FakeNamespace(); + return { namespace, env: { USER_ROOM: namespace, SESSION_SECRET: SECRET } }; +} + +function deviceRequest(cookie?: string): Request { + const headers = new Headers({ upgrade: "websocket" }); + if (cookie !== undefined) { + headers.set("cookie", cookie); + } + return new Request(new URL("/ws/device", ORIGIN), { headers }); +} + +function hostRequest(options: { token?: string; hostId?: string; label?: string }): Request { + const headers = new Headers({ upgrade: "websocket" }); + if (options.token !== undefined) { + headers.set("authorization", `Bearer ${options.token}`); + } + if (options.hostId !== undefined) { + headers.set("x-canopy-host-id", options.hostId); + } + if (options.label !== undefined) { + headers.set("x-canopy-host-label", options.label); + } + return new Request(new URL("/ws/host", ORIGIN), { headers }); +} + +const githubUser = (id = 456, login = "host-user") => + fakeFetch([{ url: "api.github.com/user", status: 200, body: { id, login } }]); + +beforeEach(() => { + __clearHostCache(); +}); + +describe("GET /ws/device", () => { + it("forwards a valid session to its own room", async () => { + const { namespace, env } = setup(); + const session = await signSession(789, "octo", SECRET, Math.floor(Date.now() / 1000)); + const response = await handleDeviceWs(deviceRequest(`cr_session=${session}; other=1`), env); + expect(response.status).toBe(200); + expect(namespace.names).toEqual(["789"]); + const forwarded = namespace.forwarded[0] as Request; + expect(forwarded.headers.get("x-relay-role")).toBe("device"); + expect(forwarded.headers.get("cookie")).toBeNull(); + expect(forwarded.headers.get("authorization")).toBeNull(); + }); + + it("answers 401 without a session or with a forged or expired one", async () => { + const { namespace, env } = setup(); + const now = Math.floor(Date.now() / 1000); + const forged = await signSession(789, "octo", "wrong-secret", now); + const expired = await signSession(789, "octo", SECRET, now - 50000); + for (const cookie of [undefined, "cr_session=forged.value", `cr_session=${forged}`, `cr_session=${expired}`]) { + const response = await handleDeviceWs(deviceRequest(cookie), env); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + } + expect(namespace.names).toEqual([]); + }); +}); + +describe("GET /ws/host", () => { + it("forwards a verified host with relay headers and no credentials", async () => { + const { namespace, env } = setup(); + const fake = githubUser(); + const response = await handleHostWs( + hostRequest({ token: "gho_valid", hostId: HOST_ID, label: "devbox" }), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(200); + expect(namespace.names).toEqual(["456"]); + const forwarded = namespace.forwarded[0] as Request; + expect(forwarded.headers.get("x-relay-role")).toBe("host"); + expect(forwarded.headers.get("x-relay-host-id")).toBe(HOST_ID); + expect(forwarded.headers.get("x-relay-label")).toBe("devbox"); + expect(forwarded.headers.get("cookie")).toBeNull(); + expect(forwarded.headers.get("authorization")).toBeNull(); + }); + + it("answers 401 for a missing, malformed or rejected bearer", async () => { + const { namespace, env } = setup(); + const rejected = fakeFetch([{ url: "api.github.com/user", status: 401, body: { message: "Bad" } }]); + const malformed = new Request(new URL("/ws/host", ORIGIN), { + headers: { authorization: "Token gho_valid", "x-canopy-host-id": HOST_ID, "x-canopy-host-label": "devbox" }, + }); + for (const req of [ + hostRequest({ hostId: HOST_ID, label: "devbox" }), + malformed, + hostRequest({ token: "gho_bad", hostId: HOST_ID, label: "devbox" }), + ]) { + const response = await handleHostWs(req, env, rejected.fetchImpl); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + } + expect(namespace.names).toEqual([]); + }); + + it("answers 400 for a host id that is not 16 lowercase hex", async () => { + const { env } = setup(); + const fake = githubUser(); + for (const hostId of ["0123456789ABCDEF", "0123456789abcde", "0123456789abcdef0", "not-a-host-id"]) { + const response = await handleHostWs( + hostRequest({ token: "gho_valid", hostId, label: "devbox" }), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "bad_host_id" }); + } + expect(fake.calls).toEqual([]); + }); + + it("answers 400 for a missing, empty or overlong label", async () => { + const { env } = setup(); + const fake = githubUser(); + for (const label of ["", "x".repeat(65)]) { + const response = await handleHostWs( + hostRequest({ token: "gho_valid", hostId: HOST_ID, label }), + env, + fake.fetchImpl, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "bad_label" }); + } + const missing = await handleHostWs( + hostRequest({ token: "gho_valid", hostId: HOST_ID }), + env, + fake.fetchImpl, + ); + expect(missing.status).toBe(400); + expect(fake.calls).toEqual([]); + }); + + it("caches a verified token and skips GitHub on the second call", async () => { + const { namespace, env } = setup(); + const fake = githubUser(); + const first = await handleHostWs( + hostRequest({ token: "gho_valid", hostId: HOST_ID, label: "devbox" }), + env, + fake.fetchImpl, + ); + const second = await handleHostWs( + hostRequest({ token: "gho_valid", hostId: HOST_ID, label: "devbox" }), + env, + fake.fetchImpl, + ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(fake.calls.filter((url) => url.includes("api.github.com/user"))).toHaveLength(1); + expect(namespace.names).toEqual(["456", "456"]); + }); +}); diff --git a/apps/remote/worker/test/room.test.ts b/apps/remote/worker/test/room.test.ts new file mode 100644 index 0000000..686d313 --- /dev/null +++ b/apps/remote/worker/test/room.test.ts @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: Apache-2.0 +import { env } from "cloudflare:test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index.js"; +import { UserRoom } from "../src/room.js"; +import type { RoomAttachment } from "../src/protocol.js"; +import { FakeRoomState, FakeRoomSocket } from "./fakes.js"; +import roomSource from "../src/room.ts?raw"; + +const HOST_ID = "0123456789abcdef"; +const OTHER_HOST_ID = "fedcba9876543210"; + +function setup(): { state: FakeRoomState; room: UserRoom } { + const state = new FakeRoomState(); + return { state, room: new UserRoom(state) }; +} + +/** + * An accepted fake socket, with the conn the room would have assigned. + * Mirrors fetch's accept path without the real pair. + */ +function attachFake( + state: FakeRoomState, + room: UserRoom, + role: "device" | "host", + extra: { host_id?: string; label?: string } = {}, +): FakeRoomSocket { + const socket = state.makeSocket(); + state.acceptWebSocket(socket); + const conn = String(room.nextConn); + room.nextConn += 1; + const attachment: RoomAttachment = { role, conn, ...extra }; + socket.serializeAttachment(attachment); + return socket; +} + +function upgradeRequest(role: string, extra: Record = {}): Request { + const headers = new Headers({ upgrade: "websocket", "x-relay-role": role, ...extra }); + return new Request("https://user-room/", { headers }); +} + +/** A real client socket, narrowed to what the tests drive. */ +interface TestClient { + accept(): void; + send(message: string): void; + addEventListener(type: string, listener: (event: unknown) => void, options?: { once?: boolean }): void; +} + +function clientOf(response: Response): TestClient { + const client = (response as unknown as { webSocket?: TestClient }).webSocket; + if (!client) { + throw new Error("expected a 101 carrying a client socket"); + } + return client; +} + +function nextMessage(client: TestClient): Promise { + return new Promise((resolve) => { + client.addEventListener( + "message", + (event) => resolve(String((event as unknown as { data: unknown }).data)), + { once: true }, + ); + }); +} + +function nextClose(client: TestClient): Promise { + return new Promise((resolve) => { + client.addEventListener( + "close", + (event) => resolve((event as unknown as { code: number }).code), + { once: true }, + ); + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("room fetch", () => { + it("answers 426 without a websocket upgrade", () => { + const { room } = setup(); + const response = room.fetch(new Request("https://user-room/")); + expect(response.status).toBe(426); + }); + + it("answers 400 for a missing role or a host without an id", () => { + const { room } = setup(); + const noRole = room.fetch(upgradeRequest("browser")); + expect(noRole.status).toBe(400); + const noId = room.fetch(upgradeRequest("host", { "x-relay-label": "devbox" })); + expect(noId.status).toBe(400); + }); + + it("accepts a device and sends it the hosts list", async () => { + const { state, room } = setup(); + const response = room.fetch(upgradeRequest("device")); + expect(response.status).toBe(101); + expect(state.getWebSockets()).toHaveLength(1); + expect(state.getWebSockets()[0]?.deserializeAttachment()).toEqual({ role: "device", conn: "1" }); + const client = clientOf(response); + client.accept(); + expect(JSON.parse(await nextMessage(client))).toEqual({ + from: "relay", + body: { t: "hosts", items: [] }, + }); + }); + + it("broadcasts to every device when a host connects", async () => { + const { room } = setup(); + const first = clientOf(room.fetch(upgradeRequest("device"))); + first.accept(); + expect(JSON.parse(await nextMessage(first))).toEqual({ + from: "relay", + body: { t: "hosts", items: [] }, + }); + const hostResponse = room.fetch( + upgradeRequest("host", { "x-relay-host-id": HOST_ID, "x-relay-label": "devbox" }), + ); + expect(hostResponse.status).toBe(101); + expect(JSON.parse(await nextMessage(first))).toEqual({ + from: "relay", + body: { t: "hosts", items: [{ host_id: HOST_ID, label: "devbox" }] }, + }); + const second = clientOf(room.fetch(upgradeRequest("device"))); + second.accept(); + expect(JSON.parse(await nextMessage(second))).toEqual({ + from: "relay", + body: { t: "hosts", items: [{ host_id: HOST_ID, label: "devbox" }] }, + }); + }); + + it("broadcasts to every device when a host disconnects", async () => { + const { state, room } = setup(); + const device = clientOf(room.fetch(upgradeRequest("device"))); + device.accept(); + await nextMessage(device); + room.fetch(upgradeRequest("host", { "x-relay-host-id": HOST_ID, "x-relay-label": "devbox" })); + await nextMessage(device); + const hostSocket = state.getWebSockets().find( + (socket) => socket.deserializeAttachment()?.role === "host", + ); + if (hostSocket === undefined) { + throw new Error("expected the host socket to be attached"); + } + // The runtime drops a closed socket before calling webSocketClose. + hostSocket.close(1000, "gone"); + state.remove(hostSocket); + room.webSocketClose(hostSocket); + expect(JSON.parse(await nextMessage(device))).toEqual({ + from: "relay", + body: { t: "hosts", items: [] }, + }); + }); + + it("replaces a host that reconnects with the same id", async () => { + const { state, room } = setup(); + const first = clientOf( + room.fetch(upgradeRequest("host", { "x-relay-host-id": HOST_ID, "x-relay-label": "old" })), + ); + first.accept(); + const replaced = state.getWebSockets(); + const closed = nextClose(first); + const second = clientOf( + room.fetch(upgradeRequest("host", { "x-relay-host-id": HOST_ID, "x-relay-label": "new" })), + ); + second.accept(); + expect(await closed).toBe(4000); + // Mirror the runtime: the replaced socket leaves before its close lands. + for (const socket of replaced) { + state.remove(socket); + room.webSocketClose(socket); + } + const device = clientOf(room.fetch(upgradeRequest("device"))); + device.accept(); + expect(JSON.parse(await nextMessage(device))).toEqual({ + from: "relay", + body: { t: "hosts", items: [{ host_id: HOST_ID, label: "new" }] }, + }); + }); + + it("refuses the 9th device with code 4008", async () => { + const { state, room } = setup(); + for (let index = 0; index < 8; index += 1) { + const response = room.fetch(upgradeRequest("device")); + expect(response.status).toBe(101); + } + expect(state.getWebSockets()).toHaveLength(8); + const ninth = clientOf(room.fetch(upgradeRequest("device"))); + ninth.accept(); + expect(await nextClose(ninth)).toBe(4008); + expect(state.getWebSockets()).toHaveLength(8); + }); +}); + +describe("routing", () => { + it("delivers device to host and host to device with rewritten sources", () => { + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + const host = attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "offer", sdp: "v=0" } })); + expect(host.frames()).toEqual([{ from: "d:1", body: { t: "offer", sdp: "v=0" } }]); + room.webSocketMessage(host, JSON.stringify({ to: "d:1", body: { t: "answer", sdp: "v=0" } })); + expect(device.frames()).toEqual([{ from: `h:${HOST_ID}`, body: { t: "answer", sdp: "v=0" } }]); + }); + + it("bounces unknown targets and role-mismatched prefixes", () => { + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + const host = attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + room.webSocketMessage(device, JSON.stringify({ to: "h:deadbeefdeadbeef", body: { t: "offer" } })); + room.webSocketMessage(device, JSON.stringify({ to: "d:1", body: { t: "offer" } })); + room.webSocketMessage(host, JSON.stringify({ to: "d:999", body: { t: "answer" } })); + room.webSocketMessage(host, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "answer" } })); + const errors = [{ from: "relay", body: { t: "error", code: "no_target" } }]; + expect(device.frames()).toEqual([...errors, ...errors]); + expect(host.frames()).toEqual([...errors, ...errors]); + }); + + it("drops frames over 64 KiB and delivers frames of exactly 64 KiB", () => { + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + const host = attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + const prefix = `{"to":"h:${HOST_ID}","body":"`; + const suffix = `"}`; + const fitting = `${prefix}${"x".repeat(65536 - prefix.length - suffix.length)}${suffix}`; + expect(new TextEncoder().encode(fitting).length).toBe(65536); + room.webSocketMessage(device, fitting); + expect(host.sent).toHaveLength(1); + const oversized = `${fitting}x`; + room.webSocketMessage(device, oversized); + expect(host.sent).toHaveLength(1); + expect(device.frames()).toEqual([{ from: "relay", body: { t: "error", code: "too_large" } }]); + }); + + it("rejects malformed and binary frames", () => { + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + room.webSocketMessage(device, "not json"); + room.webSocketMessage(device, JSON.stringify({ body: 1 })); + room.webSocketMessage(device, JSON.stringify({ to: 42 })); + room.webSocketMessage(device, new ArrayBuffer(4)); + const errors = [{ from: "relay", body: { t: "error", code: "bad_frame" } }]; + expect(device.frames()).toEqual([...errors, ...errors, ...errors, ...errors]); + }); +}); + +describe("pair_init rate limit", () => { + it("allows 20 pair_init frames per 10 minutes from either role", () => { + vi.useFakeTimers(); + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + const host = attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + for (let index = 0; index < 19; index += 1) { + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "pair_init" } })); + } + room.webSocketMessage(host, JSON.stringify({ to: "d:1", body: { t: "pair_init" } })); + expect(host.sent).toHaveLength(19); + expect(device.sent).toHaveLength(1); + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "pair_init" } })); + expect(device.frames()).toEqual([ + { from: `h:${HOST_ID}`, body: { t: "pair_init" } }, + { from: "relay", body: { t: "error", code: "rate_limited" } }, + ]); + expect(host.sent).toHaveLength(19); + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "offer" } })); + expect(host.sent).toHaveLength(20); + }); + + it("opens the window again after 10 minutes", () => { + vi.useFakeTimers(); + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + for (let index = 0; index < 20; index += 1) { + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "pair_init" } })); + } + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "pair_init" } })); + expect(device.sent).toHaveLength(1); + vi.advanceTimersByTime(600001); + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "pair_init" } })); + expect(device.sent).toHaveLength(1); + }); +}); + +describe("no storage", () => { + it("never touches the storage API: the source says so", () => { + expect(roomSource).not.toContain(".storage"); + }); +}); + +describe("live room", () => { + it("round-trips through the real Durable Object", async () => { + const namespace = (env as unknown as Env).USER_ROOM; + const stub = namespace.get(namespace.idFromName("room-self-test")); + const deviceResponse = await stub.fetch(upgradeRequest("device")); + expect(deviceResponse.status).toBe(101); + const device = clientOf(deviceResponse); + device.accept(); + expect(JSON.parse(await nextMessage(device))).toEqual({ + from: "relay", + body: { t: "hosts", items: [] }, + }); + // Listen before the host fetch: the broadcast can land while stub.fetch + // itself is still resolving. + const withHost = nextMessage(device); + const hostResponse = await stub.fetch( + upgradeRequest("host", { "x-relay-host-id": OTHER_HOST_ID, "x-relay-label": "live" }), + ); + expect(hostResponse.status).toBe(101); + const host = clientOf(hostResponse); + host.accept(); + expect(JSON.parse(await withHost)).toEqual({ + from: "relay", + body: { t: "hosts", items: [{ host_id: OTHER_HOST_ID, label: "live" }] }, + }); + const atHost = nextMessage(host); + device.send(JSON.stringify({ to: `h:${OTHER_HOST_ID}`, body: { t: "offer" } })); + const received = JSON.parse(await atHost) as { from: string; body: unknown }; + expect(received.body).toEqual({ t: "offer" }); + const atDevice = nextMessage(device); + host.send(JSON.stringify({ to: received.from, body: { t: "answer" } })); + expect(JSON.parse(await atDevice)).toEqual({ + from: `h:${OTHER_HOST_ID}`, + body: { t: "answer" }, + }); + }); + + it("refuses the 9th device on the real object", async () => { + const namespace = (env as unknown as Env).USER_ROOM; + const stub = namespace.get(namespace.idFromName("room-self-ninth")); + const clients: TestClient[] = []; + for (let index = 0; index < 8; index += 1) { + const response = await stub.fetch(upgradeRequest("device")); + expect(response.status).toBe(101); + const client = clientOf(response); + client.accept(); + clients.push(client); + } + const ninth = clientOf(await stub.fetch(upgradeRequest("device"))); + ninth.accept(); + expect(await nextClose(ninth)).toBe(4008); + }); +}); diff --git a/apps/remote/worker/test/turn.test.ts b/apps/remote/worker/test/turn.test.ts new file mode 100644 index 0000000..10562a4 --- /dev/null +++ b/apps/remote/worker/test/turn.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +import { beforeEach, describe, expect, it } from "vitest"; +import { signSession } from "../src/auth.js"; +import { __clearHostCache } from "../src/github.js"; +import { handleTurn, type TurnEnv } from "../src/turn.js"; +import { fakeFetch, type FakeFetch } from "./fakes.js"; + +const ORIGIN = "https://remote.univerlab.org"; +const SECRET = "test-session-secret-that-is-long-enough"; + +const env: TurnEnv = { + SESSION_SECRET: SECRET, + TURN_KEY_ID: "turn-key-id", + TURN_KEY_API_TOKEN: "turn-api-token", +}; + +const UPSTREAM = { iceServers: [{ urls: "turn:turn.example:3478", username: "user", credential: "pass" }] }; + +function request(cookie?: string, token?: string): Request { + const headers = new Headers(); + if (cookie !== undefined) { + headers.set("cookie", cookie); + } + if (token !== undefined) { + headers.set("authorization", `Bearer ${token}`); + } + return new Request(new URL("/turn", ORIGIN), { headers }); +} + +/** Wrap a fake fetch to also record the headers of every call. */ +function recording(fake: FakeFetch): { fetchImpl: typeof fetch; seen: Array> } { + const seen: Array> = []; + const fetchImpl = (async (input: URL | Request | string, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + seen.push(record); + return fake.fetchImpl(input, init); + }) as unknown as typeof fetch; + return { fetchImpl, seen }; +} + +beforeEach(() => { + __clearHostCache(); +}); + +describe("GET /turn", () => { + it("serves a cookied browser with upstream entries plus STUN", async () => { + const fake = fakeFetch([{ url: "credentials/generate", status: 200, body: UPSTREAM }]); + const { fetchImpl, seen } = recording(fake); + const session = await signSession(123, "octo", SECRET, Math.floor(Date.now() / 1000)); + const response = await handleTurn(request(`cr_session=${session}`), env, fetchImpl); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + iceServers: [...UPSTREAM.iceServers, { urls: "stun:stun.cloudflare.com:3478" }], + }); + expect(fake.calls).toHaveLength(1); + expect(fake.calls[0]).toContain("rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate"); + expect(JSON.parse(fake.bodies[0] as string)).toEqual({ ttl: 3600 }); + expect(seen[0]?.["authorization"]).toBe("Bearer turn-api-token"); + }); + + it("serves a host bearer without touching GitHub twice", async () => { + const fake = fakeFetch([ + { url: "credentials/generate", status: 200, body: UPSTREAM }, + { url: "api.github.com/user", status: 200, body: { id: 456, login: "host-user" } }, + ]); + const first = await handleTurn(request(undefined, "gho_valid"), env, fake.fetchImpl); + expect(first.status).toBe(200); + const second = await handleTurn(request(undefined, "gho_valid"), env, fake.fetchImpl); + expect(second.status).toBe(200); + expect(fake.calls.filter((url) => url.includes("api.github.com/user"))).toHaveLength(1); + }); + + it("answers 502 when the upstream fails, without forwarding its body", async () => { + const fake = fakeFetch([ + { url: "credentials/generate", status: 500, body: { internal: "details" } }, + ]); + const session = await signSession(123, "octo", SECRET, Math.floor(Date.now() / 1000)); + const response = await handleTurn(request(`cr_session=${session}`), env, fake.fetchImpl); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: "turn_unavailable" }); + }); + + it("answers 401 with neither credential", async () => { + const fake = fakeFetch([{ url: "credentials/generate", status: 200, body: UPSTREAM }]); + const response = await handleTurn(request(), env, fake.fetchImpl); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + expect(fake.calls).toEqual([]); + }); +}); diff --git a/apps/remote/worker/test/vite-raw.d.ts b/apps/remote/worker/test/vite-raw.d.ts new file mode 100644 index 0000000..64e43d0 --- /dev/null +++ b/apps/remote/worker/test/vite-raw.d.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/apps/remote/worker/vitest.config.ts b/apps/remote/worker/vitest.config.ts new file mode 100644 index 0000000..3ec7dce --- /dev/null +++ b/apps/remote/worker/vitest.config.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + include: ["apps/remote/worker/test/**/*.test.ts"], + poolOptions: { + workers: { + // Single-file Worker: the pool loads this config, so tests speak to + // the same code and bindings `wrangler deploy` will ship. + wrangler: { configPath: "./wrangler.toml" }, + // The room's sqlite-backed class leaves -shm files behind that this + // pool's isolated-storage copy chokes on; the live tests use unique + // room names, so sharing one namespace is safe. + isolatedStorage: false, + }, + }, + }, +}); diff --git a/apps/remote/worker/wrangler.toml b/apps/remote/worker/wrangler.toml new file mode 100644 index 0000000..a8e0044 --- /dev/null +++ b/apps/remote/worker/wrangler.toml @@ -0,0 +1,25 @@ +# remote.univerlab.org — blind signaling relay +# +# One Worker, one job: prove both ends belong to the same GitHub account +# and forward opaque signaling messages between them, storing nothing. +# The Worker never parses signaling bodies beyond `t` for the rate limit +# (contract: Intelligence d4eb8558). +name = "canopy-remote" +main = "src/index.ts" +compatibility_date = "2026-10-26" + +# Inlined on purpose: the account has a single Worker account, and the deploy +# workflow then needs exactly one secret (CLOUDFLARE_API_TOKEN). +account_id = "d7b81bdd721f503968523dd903d967be" + +routes = [{ pattern = "remote.univerlab.org/*", zone_name = "univerlab.org" }] + +# One room per GitHub user id. The room uses the WebSocket hibernation API +# with per-socket attachments only — it never touches the storage API. +[[durable_objects.bindings]] +name = "USER_ROOM" +class_name = "UserRoom" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["UserRoom"] diff --git a/package.json b/package.json index 1f3f8d7..42b4fa9 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "vitest run && npm run test:worker", + "test": "vitest run && npm run test:worker && npm run test:remote-worker", "test:worker": "vitest run --config apps/graphs/vitest.config.ts", + "test:remote-worker": "vitest run --config apps/remote/worker/vitest.config.ts", "validate": "tsx src/validate.ts" }, "dependencies": { diff --git a/tsconfig.json b/tsconfig.json index 4abbeae..d4d2aa8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,9 @@ "vitest.config.ts", "apps/*/src/**/*.ts", "apps/*/test/**/*.ts", - "apps/*/vitest.config.ts" + "apps/*/vitest.config.ts", + "apps/*/worker/src/**/*.ts", + "apps/*/worker/test/**/*.ts", + "apps/*/worker/vitest.config.ts" ] } From b3a3839983c789b36176b81e740db511b2c43105 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 20:43:13 -0500 Subject: [PATCH 014/180] feat(pairing): pair the browser with canopy and reconnect with its device key --- .gitignore | 1 + apps/remote/web/index.html | 30 + apps/remote/web/src/channel.ts | 117 +++ apps/remote/web/src/crypto.ts | 178 +++++ apps/remote/web/src/main.ts | 153 ++++ apps/remote/web/src/pairing.ts | 159 +++++ apps/remote/web/src/peer.ts | 350 +++++++++ apps/remote/web/src/reconnect.ts | 259 +++++++ apps/remote/web/src/relay.ts | 159 +++++ apps/remote/web/src/store.ts | 139 ++++ apps/remote/web/src/ui.ts | 248 +++++++ apps/remote/web/test/channel.test.ts | 50 ++ apps/remote/web/test/crypto.test.ts | 128 ++++ apps/remote/web/test/fakes.ts | 302 ++++++++ apps/remote/web/test/pairing.test.ts | 185 +++++ apps/remote/web/test/peer.test.ts | 212 ++++++ apps/remote/web/test/reconnect.test.ts | 286 ++++++++ apps/remote/web/test/relay.test.ts | 82 +++ apps/remote/web/test/setup.ts | 11 + apps/remote/web/test/store.test.ts | 72 ++ apps/remote/web/test/ui.test.ts | 178 +++++ apps/remote/web/test/vectors.ts | 21 + apps/remote/web/tsconfig.json | 8 + apps/remote/web/vite.config.ts | 11 + apps/remote/web/vitest.config.ts | 10 + eslint.config.js | 2 +- package-lock.json | 937 +++++++++++++++++++++++-- package.json | 8 +- tsconfig.json | 5 +- 29 files changed, 4257 insertions(+), 44 deletions(-) create mode 100644 apps/remote/web/index.html create mode 100644 apps/remote/web/src/channel.ts create mode 100644 apps/remote/web/src/crypto.ts create mode 100644 apps/remote/web/src/main.ts create mode 100644 apps/remote/web/src/pairing.ts create mode 100644 apps/remote/web/src/peer.ts create mode 100644 apps/remote/web/src/reconnect.ts create mode 100644 apps/remote/web/src/relay.ts create mode 100644 apps/remote/web/src/store.ts create mode 100644 apps/remote/web/src/ui.ts create mode 100644 apps/remote/web/test/channel.test.ts create mode 100644 apps/remote/web/test/crypto.test.ts create mode 100644 apps/remote/web/test/fakes.ts create mode 100644 apps/remote/web/test/pairing.test.ts create mode 100644 apps/remote/web/test/peer.test.ts create mode 100644 apps/remote/web/test/reconnect.test.ts create mode 100644 apps/remote/web/test/relay.test.ts create mode 100644 apps/remote/web/test/setup.ts create mode 100644 apps/remote/web/test/store.test.ts create mode 100644 apps/remote/web/test/ui.test.ts create mode 100644 apps/remote/web/test/vectors.ts create mode 100644 apps/remote/web/tsconfig.json create mode 100644 apps/remote/web/vite.config.ts create mode 100644 apps/remote/web/vitest.config.ts diff --git a/.gitignore b/.gitignore index a2519ae..f9c9f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ dist *.css *.htm *.html +!apps/remote/web/index.html *.js # The ESLint flat config is source, not a build output. !eslint.config.js diff --git a/apps/remote/web/index.html b/apps/remote/web/index.html new file mode 100644 index 0000000..d72c834 --- /dev/null +++ b/apps/remote/web/index.html @@ -0,0 +1,30 @@ + + + + + + Canopy remote + + + +
    Loading…
    + + + diff --git a/apps/remote/web/src/channel.ts b/apps/remote/web/src/channel.ts new file mode 100644 index 0000000..b11176f --- /dev/null +++ b/apps/remote/web/src/channel.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Protocol v1 data-channel frames. Snapshot bodies arrive split across one +// `snapshot` frame plus `snapshot_more` chunks; they are joined into a single +// snapshot before delivery to the session layer. Every other frame passes +// through untouched. + +export interface JoinedSnapshot { + id: string; + cols: number; + rows: number; + seq: number; + data: string; +} + +export interface ChannelHandlers { + onSnapshot: (snapshot: JoinedSnapshot) => void; + onFrame?: (frame: unknown) => void; +} + +interface SnapshotHead { + id: string; + cols: number; + rows: number; + seq: number; + data: string; +} + +function asSnapshotHead(frame: unknown): SnapshotHead | null { + if (typeof frame !== "object" || frame === null) { + return null; + } + const row = frame as Record; + if (row.t !== "snapshot") { + return null; + } + if ( + typeof row.id !== "string" || + typeof row.cols !== "number" || + typeof row.rows !== "number" || + typeof row.seq !== "number" || + typeof row.data !== "string" + ) { + return null; + } + return { id: row.id, cols: row.cols, rows: row.rows, seq: row.seq, data: row.data }; +} + +interface SnapshotChunk { + id: string; + data: string; + last: boolean; +} + +function asSnapshotChunk(frame: unknown): SnapshotChunk | null { + if (typeof frame !== "object" || frame === null) { + return null; + } + const row = frame as Record; + if (row.t !== "snapshot_more") { + return null; + } + if (typeof row.id !== "string" || typeof row.data !== "string" || typeof row.last !== "boolean") { + return null; + } + return { id: row.id, data: row.data, last: row.last }; +} + +export interface ChannelAssembler { + push: (raw: string) => void; +} + +export function createChannelHandler(handlers: ChannelHandlers): ChannelAssembler { + let pending: (SnapshotHead & { parts: string[] }) | null = null; + + const flush = () => { + if (!pending) { + return; + } + const done: JoinedSnapshot = { + id: pending.id, + cols: pending.cols, + rows: pending.rows, + seq: pending.seq, + data: pending.parts.join(""), + }; + pending = null; + handlers.onSnapshot(done); + }; + + return { + push(raw: string): void { + let frame: unknown; + try { + frame = JSON.parse(raw); + } catch { + return; + } + const head = asSnapshotHead(frame); + if (head) { + // A fresh snapshot supersedes any partial one. + pending = { ...head, parts: [head.data] }; + return; + } + const chunk = asSnapshotChunk(frame); + if (chunk) { + if (pending && pending.id === chunk.id) { + pending.parts.push(chunk.data); + if (chunk.last) { + flush(); + } + } + return; + } + handlers.onFrame?.(frame); + }, + }; +} diff --git a/apps/remote/web/src/crypto.ts b/apps/remote/web/src/crypto.ts new file mode 100644 index 0000000..c233236 --- /dev/null +++ b/apps/remote/web/src/crypto.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +// Device identity and pairing cryptography for the Canopy remote web app. +// WebCrypto only. Base64 is standard with padding. + +export const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +export const CODE_LENGTH = 10; +export const NONCE_BYTES = 16; +export const PAIR_ITERATIONS = 600000; + +const PAIR_SALT_PREFIX = "canopy-remote/v1/pair\n"; +const PAIR_INIT_PREFIX = "canopy-remote/v1/pair-init\n"; +const PAIR_OK_PREFIX = "canopy-remote/v1/pair-ok\n"; +const OFFER_PREFIX = "canopy-remote/v1/offer\n"; +const ANSWER_PREFIX = "canopy-remote/v1/answer\n"; + +export interface DeviceKey { + privateKey: CryptoKey; + spki: Uint8Array; + spkiB64: string; + deviceId: string; +} + +export function b64encode(bytes: Uint8Array): string { + const CHUNK = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +export function b64decode(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function utf8(message: string): Uint8Array { + return new TextEncoder().encode(message); +} + +// Copy into a fresh ArrayBuffer-backed view for the WebCrypto typed-array +// generics (Uint8Array is not a BufferSource). +function view(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes); +} + +function subtle(): SubtleCrypto { + return globalThis.crypto.subtle; +} + +// Uppercase, drop `-` and whitespace, fold lookalikes, then require 10 +// Crockford base32 chars. +export function normalizeCode(input: string): string { + const code = input + .toUpperCase() + .replace(/[-\s]/g, "") + .replace(/I/g, "1") + .replace(/L/g, "1") + .replace(/O/g, "0"); + if (code.length !== CODE_LENGTH || ![...code].every((c) => CODE_ALPHABET.includes(c))) { + throw new Error("Invalid pairing code"); + } + return code; +} + +export async function sha256Hex(data: Uint8Array): Promise { + const digest = await subtle().digest("SHA-256", view(data)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// device_id / host_id: first 16 hex chars of SHA-256 over the SPKI DER. +export async function idFromSpki(spkiDer: Uint8Array): Promise { + return (await sha256Hex(spkiDer)).slice(0, 16); +} + +export function randomBytes(length: number): Uint8Array { + return globalThis.crypto.getRandomValues(new Uint8Array(length)); +} + +export async function generateDeviceKey(): Promise { + const pair = await subtle().generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); + const spki = new Uint8Array(await subtle().exportKey("spki", pair.publicKey)); + const spkiB64 = b64encode(spki); + const deviceId = await idFromSpki(spki); + return { privateKey: pair.privateKey, spki, spkiB64, deviceId }; +} + +export async function importSpkiVerifyKey(spkiDer: Uint8Array): Promise { + return subtle().importKey("spki", view(spkiDer), { name: "ECDSA", namedCurve: "P-256" }, true, [ + "verify", + ]); +} + +// K_pair = PBKDF2-SHA256(code, salt, 600000, 256 bits) as an HMAC-SHA256 key. +export async function derivePairKey(codeNormalized: string, hostId: string): Promise { + const base = await subtle().importKey("raw", view(utf8(codeNormalized)), "PBKDF2", false, [ + "deriveKey", + ]); + return subtle().deriveKey( + { + name: "PBKDF2", + salt: view(utf8(PAIR_SALT_PREFIX + hostId)), + iterations: PAIR_ITERATIONS, + hash: "SHA-256", + }, + base, + { name: "HMAC", hash: "SHA-256", length: 256 }, + false, + ["sign", "verify"], + ); +} + +async function hmacB64(key: CryptoKey, message: string): Promise { + const mac = await subtle().sign("HMAC", key, view(utf8(message))); + return b64encode(new Uint8Array(mac)); +} + +export function pairInitMac(key: CryptoKey, deviceSpkiB64: string, nonceBB64: string): Promise { + return hmacB64(key, `${PAIR_INIT_PREFIX}${deviceSpkiB64}\n${nonceBB64}`); +} + +export function pairOkMac( + key: CryptoKey, + hostSpkiB64: string, + deviceSpkiB64: string, + nonceBB64: string, + nonceHB64: string, +): Promise { + return hmacB64( + key, + `${PAIR_OK_PREFIX}${hostSpkiB64}\n${deviceSpkiB64}\n${nonceBB64}\n${nonceHB64}`, + ); +} + +export function macEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i += 1) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +// Offer signature: ECDSA P-256 / SHA-256, P1363 (r||s) output, base64. +export async function signOffer( + devicePrivateKey: CryptoKey, + hostId: string, + ts: string, + sdp: string, +): Promise { + const sig = await subtle().sign( + { name: "ECDSA", hash: "SHA-256" }, + devicePrivateKey, + view(utf8(`${OFFER_PREFIX}${hostId}\n${ts}\n${sdp}`)), + ); + return b64encode(new Uint8Array(sig)); +} + +export async function verifyAnswer( + hostPublicKey: CryptoKey, + deviceId: string, + offerSigB64: string, + sdp: string, + answerSigB64: string, +): Promise { + return subtle().verify( + { name: "ECDSA", hash: "SHA-256" }, + hostPublicKey, + view(b64decode(answerSigB64)), + view(utf8(`${ANSWER_PREFIX}${deviceId}\n${offerSigB64}\n${sdp}`)), + ); +} diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts new file mode 100644 index 0000000..3d95211 --- /dev/null +++ b/apps/remote/web/src/main.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// Browser entry point: device key, relay socket, pair form, and the +// reconnecting session connection. Joined snapshots and other protocol +// frames are delivered as `canopy:snapshot` / `canopy:frame` window events +// for the session layer. + +import { createChannelHandler } from "./channel.js"; +import { generateDeviceKey } from "./crypto.js"; +import type { DeviceKey } from "./crypto.js"; +import { runPairing } from "./pairing.js"; +import { ConnectionManager } from "./reconnect.js"; +import { RelaySocket, defaultRelayUrl, parseHostsList } from "./relay.js"; +import type { OnlineHost } from "./relay.js"; +import { getHost, listHosts, loadDeviceKey, saveDeviceKey, saveHost } from "./store.js"; +import type { HostRecord } from "./store.js"; +import { consumePairFragment, createApp } from "./ui.js"; +import type { PairSubmit } from "./ui.js"; + +async function ensureDeviceKey(): Promise { + const existing = await loadDeviceKey(); + if (existing) { + return existing; + } + const fresh = await generateDeviceKey(); + await saveDeviceKey(fresh); + return fresh; +} + +async function main(): Promise { + const root = document.getElementById("app"); + if (!root) { + throw new Error("Missing #app element"); + } + const prefill = consumePairFragment(); + const device = await ensureDeviceKey(); + + const relay = new RelaySocket(defaultRelayUrl()); + let onlineHosts: OnlineHost[] = []; + let manager: ConnectionManager | null = null; + + const ui = createApp(root, { + onPair: (submit) => void doPair(submit), + onConnect: (hostId) => void doConnect(hostId), + onDisconnect: () => { + manager?.stop(); + manager = null; + ui.showHosts(); + }, + onRetry: () => manager?.retryNow(), + onPairAgain: () => { + manager?.stop(); + manager = null; + ui.showPair(); + }, + }); + + const refreshPaired = async () => { + ui.setPairedHosts(await listHosts()); + }; + + async function doPair(submit: PairSubmit): Promise { + ui.setPairBusy(true); + ui.setPairError(null); + try { + await relay.connect(); + } catch { + ui.setPairBusy(false); + ui.setPairError("Could not reach the relay"); + return; + } + const picked = onlineHosts.find((host) => host.host_id === submit.hostId); + const stored = await getHost(submit.hostId); + const hostLabel = picked?.label ?? stored?.label ?? submit.hostId; + try { + const host = await runPairing({ + relay, + hostId: submit.hostId, + hostLabel, + code: submit.code, + deviceLabel: submit.label, + device, + saveHost, + }); + await refreshPaired(); + startSession(host); + } catch (err) { + ui.setPairError(err instanceof Error ? err.message : "Pairing failed"); + } finally { + ui.setPairBusy(false); + } + } + + async function doConnect(hostId: string): Promise { + const host = await getHost(hostId); + if (!host) { + await refreshPaired(); + ui.showHosts(); + return; + } + startSession(host); + } + + function startSession(host: HostRecord): void { + manager?.stop(); + ui.showSession(host.label); + const frames = createChannelHandler({ + onSnapshot: (snapshot) => { + globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); + }, + onFrame: (frame) => { + globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); + }, + }); + const next = new ConnectionManager({ relay, host, device }); + manager = next; + next.onState((state) => ui.setConnectionState(state)); + next.onMessage((data) => frames.push(data)); + next.start(); + } + + relay.onFrame((frame) => { + if (frame.from !== "relay") { + return; + } + const hosts = parseHostsList(frame.body); + if (hosts) { + onlineHosts = hosts; + ui.setOnlineHosts(hosts); + } + }); + + await refreshPaired(); + if (prefill !== null) { + ui.showPair(prefill); + } else if ((await listHosts()).length > 0) { + ui.showHosts(); + } else { + ui.showPair(); + } + + try { + await relay.connect(); + } catch { + ui.setPairError("Could not reach the relay"); + } +} + +main().catch((err: unknown) => { + const root = document.getElementById("app"); + if (root) { + root.textContent = err instanceof Error ? err.message : "Could not start the app"; + } +}); diff --git a/apps/remote/web/src/pairing.ts b/apps/remote/web/src/pairing.ts new file mode 100644 index 0000000..3707e2d --- /dev/null +++ b/apps/remote/web/src/pairing.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Pairing: the code-authenticated key exchange with a host through the relay. +// The code never leaves the page: it is derived into K_pair locally and is +// never sent, logged, or stored. + +import { + NONCE_BYTES, + b64decode, + b64encode, + derivePairKey, + idFromSpki, + macEqual, + normalizeCode, + pairInitMac, + pairOkMac, + randomBytes, +} from "./crypto.js"; +import type { DeviceKey } from "./crypto.js"; +import type { RelayFrame, RelaySocket } from "./relay.js"; +import type { HostRecord } from "./store.js"; + +const PAIR_TIMEOUT_MS = 60000; + +export function pairFailMessage(reason: string): string { + if (reason === "no_code") { + return "No pairing code is open on this host"; + } + if (reason === "expired") { + return "The code expired; create a new one in canopy"; + } + if (reason === "bad_code") { + return "Wrong code"; + } + return `Pairing failed (${reason})`; +} + +export function defaultDeviceLabel(): string { + const nav = globalThis.navigator; + const ua = nav.userAgent; + let browser = "Browser"; + if (/Firefox\//.test(ua)) { + browser = "Firefox"; + } else if (/Edg\//.test(ua)) { + browser = "Edge"; + } else if (/Chrome\//.test(ua)) { + browser = "Chrome"; + } else if (/Safari\//.test(ua)) { + browser = "Safari"; + } + const data = (nav as Navigator & { userAgentData?: { platform?: string } }).userAgentData; + const platform = data?.platform ?? nav.platform ?? ""; + return `${browser} on ${platform === "" ? "unknown" : platform}`; +} + +export interface PairingDeps { + relay: RelaySocket; + hostId: string; + hostLabel: string; + code: string; + deviceLabel: string; + device: DeviceKey; + saveHost: (host: HostRecord) => Promise; + randomNonce?: () => Uint8Array; + timeoutMs?: number; +} + +interface PairOkBody { + host_spki: string; + nonce_h: string; + mac: string; +} + +function asPairOk(body: unknown): PairOkBody | null { + if (typeof body !== "object" || body === null) { + return null; + } + const row = body as Record; + if (row.t !== "pair_ok") { + return null; + } + if (typeof row.host_spki !== "string" || typeof row.nonce_h !== "string" || typeof row.mac !== "string") { + return null; + } + return { host_spki: row.host_spki, nonce_h: row.nonce_h, mac: row.mac }; +} + +function asPairFail(body: unknown): string | null { + if (typeof body !== "object" || body === null) { + return null; + } + const row = body as Record; + if (row.t !== "pair_fail" || typeof row.reason !== "string") { + return null; + } + return row.reason; +} + +export async function runPairing(deps: PairingDeps): Promise { + const code = normalizeCode(deps.code); + const pairKey = await derivePairKey(code, deps.hostId); + const nonceB = deps.randomNonce ? deps.randomNonce() : randomBytes(NONCE_BYTES); + const nonceBB64 = b64encode(nonceB); + const mac = await pairInitMac(pairKey, deps.device.spkiB64, nonceBB64); + + deps.relay.send(`h:${deps.hostId}`, { + t: "pair_init", + device_spki: deps.device.spkiB64, + device_label: deps.deviceLabel, + nonce_b: nonceBB64, + mac, + }); + + const fromHost = `h:${deps.hostId}`; + const reply = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Pairing timed out")); + }, deps.timeoutMs ?? PAIR_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timer); + unsubscribe(); + }; + const unsubscribe = deps.relay.onFrame((frame: RelayFrame) => { + if (frame.from !== fromHost) { + return; + } + const failReason = asPairFail(frame.body); + if (failReason !== null) { + cleanup(); + reject(new Error(pairFailMessage(failReason))); + return; + } + const ok = asPairOk(frame.body); + if (ok !== null) { + cleanup(); + resolve(ok); + } + }); + }); + + const expected = await pairOkMac( + pairKey, + reply.host_spki, + deps.device.spkiB64, + nonceBB64, + reply.nonce_h, + ); + if (!macEqual(expected, reply.mac)) { + throw new Error("Host identity check failed"); + } + const spkiId = await idFromSpki(b64decode(reply.host_spki)); + if (!spkiId.startsWith(deps.hostId)) { + throw new Error("Host identity check failed"); + } + + const host: HostRecord = { host_id: deps.hostId, host_spki: reply.host_spki, label: deps.hostLabel }; + await deps.saveHost(host); + return host; +} diff --git a/apps/remote/web/src/peer.ts b/apps/remote/web/src/peer.ts new file mode 100644 index 0000000..720b5e0 --- /dev/null +++ b/apps/remote/web/src/peer.ts @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: Apache-2.0 +// WebRTC connection to one host: TURN fetch, signed offer/answer through the +// relay, trickled ICE, one ordered data channel. Single-use: the connection +// manager builds a fresh peer per attempt. + +import { b64decode, importSpkiVerifyKey, signOffer, verifyAnswer } from "./crypto.js"; +import type { DeviceKey } from "./crypto.js"; +import type { RelayFrame, RelaySocket } from "./relay.js"; +import type { HostRecord } from "./store.js"; + +export const DATA_CHANNEL_LABEL = "canopy-session-v1"; + +export type PeerConnectionFactory = (config: RTCConfiguration) => RTCPeerConnection; +export type TurnFetcher = (url: string) => Promise<{ ok: boolean; json(): Promise }>; + +export function connectFailMessage(reason: string): string { + if (reason === "unknown_device") { + return "This browser is no longer paired"; + } + if (reason === "stale") { + return "Clock skew: check this device's time"; + } + if (reason === "busy") { + return "Too many devices connected"; + } + return `Connection failed (${reason})`; +} + +export type PeerEvent = + | { type: "open" } + | { type: "closed" } + | { type: "failed"; reason: string } + | { type: "message"; data: string } + | { type: "ice"; state: string }; + +export interface PeerDeps { + relay: RelaySocket; + host: HostRecord; + device: DeviceKey; + fetchTurn?: TurnFetcher; + createConnection?: PeerConnectionFactory; + nowMs?: () => number; +} + +interface AnswerBody { + sdp: string; + sig: string; +} + +function asAnswer(body: unknown): AnswerBody | null { + if (typeof body !== "object" || body === null) { + return null; + } + const row = body as Record; + if (row.t !== "answer" || typeof row.sdp !== "string" || typeof row.sig !== "string") { + return null; + } + return { sdp: row.sdp, sig: row.sig }; +} + +function asIceCandidate(body: unknown): RTCIceCandidateInit | null { + if (typeof body !== "object" || body === null) { + return null; + } + const row = body as Record; + if (row.t !== "ice" || typeof row.candidate !== "object" || row.candidate === null) { + return null; + } + const init = row.candidate as Record; + if (typeof init.candidate !== "string") { + return null; + } + return { + candidate: init.candidate, + sdpMid: typeof init.sdpMid === "string" ? init.sdpMid : null, + sdpMLineIndex: typeof init.sdpMLineIndex === "number" ? init.sdpMLineIndex : null, + }; +} + +function asConnectFail(body: unknown): string | null { + if (typeof body !== "object" || body === null) { + return null; + } + const row = body as Record; + if (row.t !== "connect_fail" || typeof row.reason !== "string") { + return null; + } + return row.reason; +} + +function isBye(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + (body as Record).t === "bye" + ); +} + +async function loadIceServers(fetchTurn: TurnFetcher): Promise { + const res = await fetchTurn("/turn"); + if (!res.ok) { + throw new Error("Could not load TURN credentials"); + } + const data = await res.json(); + if (Array.isArray(data)) { + return data as RTCIceServer[]; + } + if (typeof data === "object" && data !== null) { + const servers = (data as Record).iceServers; + if (Array.isArray(servers)) { + return servers as RTCIceServer[]; + } + } + throw new Error("Could not load TURN credentials"); +} + +export class HostPeer { + private readonly relay: RelaySocket; + private readonly host: HostRecord; + private readonly device: DeviceKey; + private readonly fetchTurn: TurnFetcher; + private readonly createConnection: PeerConnectionFactory; + private readonly nowMs: () => number; + private readonly handlers = new Set<(ev: PeerEvent) => void>(); + private readonly queuedCandidates: RTCIceCandidateInit[] = []; + private pc: RTCPeerConnection | null = null; + private channel: RTCDataChannel | null = null; + private unsubscribe: (() => void) | null = null; + private hostKey: CryptoKey | null = null; + private offerSig = ""; + private started = false; + private state: "idle" | "starting" | "open" | "done" = "idle"; + private settleOpen: { resolve: () => void; reject: (err: Error) => void } | null = null; + + constructor(deps: PeerDeps) { + this.relay = deps.relay; + this.host = deps.host; + this.device = deps.device; + this.fetchTurn = deps.fetchTurn ?? ((url) => globalThis.fetch(url)); + this.createConnection = deps.createConnection ?? ((config) => new RTCPeerConnection(config)); + this.nowMs = deps.nowMs ?? (() => Date.now()); + } + + onEvent(handler: (ev: PeerEvent) => void): () => void { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + private emit(ev: PeerEvent): void { + for (const handler of this.handlers) { + handler(ev); + } + } + + private get target(): string { + return `h:${this.host.host_id}`; + } + + async connect(): Promise { + if (this.started) { + throw new Error("Peer is already running"); + } + this.started = true; + this.state = "starting"; + try { + const iceServers = await loadIceServers(this.fetchTurn); + this.hostKey = await importSpkiVerifyKey(b64decode(this.host.host_spki)); + const pc = this.createConnection({ iceServers }); + this.pc = pc; + const channel = pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true }); + this.channel = channel; + channel.onopen = () => { + if (this.state !== "starting") { + return; + } + this.state = "open"; + this.settleOpen?.resolve(); + this.settleOpen = null; + this.emit({ type: "open" }); + }; + channel.onclose = () => this.remoteClosed(); + channel.onmessage = (ev: MessageEvent) => { + if (typeof ev.data === "string") { + this.emit({ type: "message", data: ev.data }); + } + }; + pc.onconnectionstatechange = () => { + this.emit({ type: "ice", state: pc.connectionState }); + }; + pc.onicecandidate = (ev: RTCPeerConnectionIceEvent) => { + if (ev.candidate) { + this.relay.trySend(this.target, { t: "ice", candidate: ev.candidate.toJSON() }); + } + }; + this.unsubscribe = this.relay.onFrame((frame: RelayFrame) => { + if (frame.from === this.target) { + void this.onHostFrame(frame.body); + } + }); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const sdp = pc.localDescription?.sdp ?? offer.sdp; + if (!sdp) { + throw new Error("Could not create an offer"); + } + const ts = String(Math.floor(this.nowMs() / 1000)); + this.offerSig = await signOffer(this.device.privateKey, this.host.host_id, ts, sdp); + this.relay.send(this.target, { + t: "offer", + device_id: this.device.deviceId, + ts, + sdp, + sig: this.offerSig, + }); + await new Promise((resolve, reject) => { + this.settleOpen = { resolve, reject }; + }); + } catch (err) { + this.teardown(true); + throw err instanceof Error ? err : new Error("Connection failed"); + } + } + + close(): void { + if (this.state === "starting") { + this.settleOpen?.reject(new Error("Connection closed")); + this.settleOpen = null; + } + this.teardown(true); + } + + private fail(reason: string): void { + if (this.state === "done") { + return; + } + this.settleOpen?.reject(new Error(reason)); + this.settleOpen = null; + this.teardown(true); + this.emit({ type: "failed", reason }); + } + + private remoteClosed(): void { + if (this.state === "done") { + return; + } + this.settleOpen?.reject(new Error("Connection closed before it opened")); + this.settleOpen = null; + this.teardown(false); + this.emit({ type: "closed" }); + } + + private async onHostFrame(body: unknown): Promise { + if (this.state === "done" || this.state === "idle") { + return; + } + const failReason = asConnectFail(body); + if (failReason !== null) { + this.fail(connectFailMessage(failReason)); + return; + } + if (isBye(body)) { + this.remoteClosed(); + return; + } + const candidate = asIceCandidate(body); + if (candidate !== null) { + await this.addRemoteCandidate(candidate); + return; + } + const answer = asAnswer(body); + if (answer !== null) { + await this.onAnswer(answer); + } + } + + private async onAnswer(answer: AnswerBody): Promise { + if (!this.pc || !this.hostKey || this.offerSig === "") { + return; + } + const valid = await verifyAnswer( + this.hostKey, + this.device.deviceId, + this.offerSig, + answer.sdp, + answer.sig, + ); + if (!valid) { + this.fail("Host identity check failed"); + return; + } + try { + await this.pc.setRemoteDescription({ type: "answer", sdp: answer.sdp }); + } catch { + this.fail("Connection failed"); + return; + } + const queued = this.queuedCandidates.splice(0, this.queuedCandidates.length); + for (const init of queued) { + await this.addRemoteCandidate(init); + } + } + + private async addRemoteCandidate(init: RTCIceCandidateInit): Promise { + if (!this.pc || this.state === "done") { + return; + } + if (!this.pc.remoteDescription) { + this.queuedCandidates.push(init); + return; + } + try { + await this.pc.addIceCandidate(init); + } catch { + // A malformed candidate must not kill the connection. + } + } + + private teardown(sendBye: boolean): void { + const first = this.state !== "done"; + this.state = "done"; + this.unsubscribe?.(); + this.unsubscribe = null; + if (this.channel) { + this.channel.onopen = null; + this.channel.onclose = null; + this.channel.onmessage = null; + try { + this.channel.close(); + } catch { + // Already closed. + } + this.channel = null; + } + if (this.pc) { + this.pc.onconnectionstatechange = null; + this.pc.onicecandidate = null; + try { + this.pc.close(); + } catch { + // Already closed. + } + this.pc = null; + } + if (sendBye && first) { + this.relay.trySend(this.target, { t: "bye" }); + } + } +} diff --git a/apps/remote/web/src/reconnect.ts b/apps/remote/web/src/reconnect.ts new file mode 100644 index 0000000..894a1a5 --- /dev/null +++ b/apps/remote/web/src/reconnect.ts @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +// Connection manager: one attempt after another with backoff, pausing while +// the page is hidden. States are plain strings the UI reads directly: +// "connecting", "connected", "retrying in Ns", "failed: ". + +import { HostPeer } from "./peer.js"; +import type { PeerDeps, PeerEvent, PeerConnectionFactory, TurnFetcher } from "./peer.js"; +import type { RelaySocket } from "./relay.js"; +import type { DeviceKey } from "./crypto.js"; +import type { HostRecord } from "./store.js"; + +export const BACKOFF_SECONDS = [1, 2, 4, 8, 16, 30]; +const DISCONNECT_GRACE_MS = 5000; +const COUNTDOWN_TICK_MS = 1000; + +export interface Visibility { + isVisible: () => boolean; + onChange: (callback: () => void) => () => void; +} + +export function documentVisibility(): Visibility { + return { + isVisible: () => globalThis.document.visibilityState === "visible", + onChange: (callback) => { + const listener = () => callback(); + globalThis.document.addEventListener("visibilitychange", listener); + return () => globalThis.document.removeEventListener("visibilitychange", listener); + }, + }; +} + +export interface ManagerDeps { + relay: RelaySocket; + host: HostRecord; + device: DeviceKey; + fetchTurn?: TurnFetcher; + createConnection?: PeerConnectionFactory; + createPeer?: (deps: PeerDeps) => HostPeer; + visibility?: Visibility; +} + +type Phase = "idle" | "attempting" | "open" | "waiting" | "failed"; + +export class ConnectionManager { + private readonly deps: ManagerDeps; + private readonly visibility: Visibility; + private readonly stateHandlers = new Set<(state: string) => void>(); + private readonly messageHandlers = new Set<(data: string) => void>(); + private phase: Phase = "idle"; + private failures = 0; + private peer: HostPeer | null = null; + private unsubscribePeer: (() => void) | null = null; + private countdown: ReturnType | null = null; + private graceTimer: ReturnType | null = null; + private stopWatchingVisibility: (() => void) | null = null; + + constructor(deps: ManagerDeps) { + this.deps = deps; + this.visibility = deps.visibility ?? documentVisibility(); + } + + onState(handler: (state: string) => void): () => void { + this.stateHandlers.add(handler); + return () => { + this.stateHandlers.delete(handler); + }; + } + + // Raw data-channel text, for the frame assembler. Only fires while open. + onMessage(handler: (data: string) => void): () => void { + this.messageHandlers.add(handler); + return () => { + this.messageHandlers.delete(handler); + }; + } + + private emit(state: string): void { + for (const handler of this.stateHandlers) { + handler(state); + } + } + + start(): void { + if (this.phase === "attempting" || this.phase === "open" || this.phase === "waiting") { + return; + } + this.failures = 0; + void this.attempt(); + } + + retryNow(): void { + if (this.phase !== "failed") { + return; + } + this.failures = 0; + void this.attempt(); + } + + stop(): void { + this.clearTimers(); + this.unsubscribePeer?.(); + this.unsubscribePeer = null; + this.peer?.close(); + this.peer = null; + this.phase = "idle"; + } + + private async attempt(): Promise { + if (this.phase === "attempting" || this.phase === "open") { + return; + } + this.clearTimers(); + this.phase = "attempting"; + this.emit("connecting"); + try { + await this.deps.relay.connect(); + } catch { + this.beginWait(); + return; + } + if (this.phase !== "attempting") { + return; + } + const createPeer = this.deps.createPeer ?? ((peerDeps) => new HostPeer(peerDeps)); + const peer = createPeer({ + relay: this.deps.relay, + host: this.deps.host, + device: this.deps.device, + fetchTurn: this.deps.fetchTurn, + createConnection: this.deps.createConnection, + }); + this.peer = peer; + this.unsubscribePeer = peer.onEvent((ev) => this.onPeerEvent(ev)); + try { + await peer.connect(); + } catch { + if (this.phase === "attempting") { + this.beginWait(); + } + } + } + + private onPeerEvent(ev: PeerEvent): void { + if (ev.type === "open") { + if (this.phase !== "attempting") { + return; + } + this.phase = "open"; + this.failures = 0; + this.emit("connected"); + return; + } + if (ev.type === "closed") { + if (this.phase === "attempting" || this.phase === "open") { + this.beginWait(); + } + return; + } + if (ev.type === "failed") { + if (this.phase === "attempting" || this.phase === "open") { + this.detachPeer(); + this.phase = "failed"; + this.emit(`failed: ${ev.reason}`); + } + return; + } + if (ev.type === "ice") { + this.onIceState(ev.state); + return; + } + if (ev.type === "message" && this.phase === "open") { + for (const handler of this.messageHandlers) { + handler(ev.data); + } + } + } + + private onIceState(state: string): void { + if (this.phase !== "open") { + return; + } + if (state === "failed") { + this.beginWait(); + return; + } + if (state === "disconnected") { + if (this.graceTimer !== null) { + return; + } + this.graceTimer = setTimeout(() => { + this.graceTimer = null; + if (this.phase === "open") { + this.beginWait(); + } + }, DISCONNECT_GRACE_MS); + return; + } + if (state === "connected" || state === "completed") { + if (this.graceTimer !== null) { + clearTimeout(this.graceTimer); + this.graceTimer = null; + } + } + } + + private beginWait(): void { + if (this.phase !== "attempting" && this.phase !== "open") { + return; + } + this.detachPeer(); + this.phase = "waiting"; + const delay = BACKOFF_SECONDS[Math.min(this.failures, BACKOFF_SECONDS.length - 1)] ?? 30; + this.failures += 1; + let remaining = delay; + this.emit(`retrying in ${remaining}s`); + this.stopWatchingVisibility?.(); + this.stopWatchingVisibility = this.visibility.onChange(() => { + if (this.phase === "waiting" && this.visibility.isVisible()) { + void this.attempt(); + } + }); + this.countdown = setInterval(() => { + if (this.phase !== "waiting" || !this.visibility.isVisible()) { + return; + } + remaining -= 1; + if (remaining <= 0) { + void this.attempt(); + } else { + this.emit(`retrying in ${remaining}s`); + } + }, COUNTDOWN_TICK_MS); + } + + private detachPeer(): void { + this.unsubscribePeer?.(); + this.unsubscribePeer = null; + this.peer?.close(); + this.peer = null; + if (this.graceTimer !== null) { + clearTimeout(this.graceTimer); + this.graceTimer = null; + } + } + + private clearTimers(): void { + if (this.countdown !== null) { + clearInterval(this.countdown); + this.countdown = null; + } + if (this.graceTimer !== null) { + clearTimeout(this.graceTimer); + this.graceTimer = null; + } + this.stopWatchingVisibility?.(); + this.stopWatchingVisibility = null; + this.detachPeer(); + } +} diff --git a/apps/remote/web/src/relay.ts b/apps/remote/web/src/relay.ts new file mode 100644 index 0000000..0e5cd55 --- /dev/null +++ b/apps/remote/web/src/relay.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Relay WebSocket client: JSON envelopes {to, body} out, {from, body} in, +// over wss:///ws/device. + +export interface RelayFrame { + from: string; + body: unknown; +} + +export interface OnlineHost { + host_id: string; + label: string; +} + +export type WebSocketFactory = (url: string) => WebSocket; + +export function defaultRelayUrl(): string { + const { protocol, host } = globalThis.location; + return `${protocol === "https:" ? "wss:" : "ws:"}//${host}/ws/device`; +} + +function parseFrame(data: unknown): RelayFrame | null { + if (typeof data !== "string") { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const record = parsed as Record; + if (typeof record.from !== "string" || !("body" in record)) { + return null; + } + return { from: record.from, body: record.body }; +} + +// The relay's {t:"hosts", items} notice listing online hosts. +export function parseHostsList(body: unknown): OnlineHost[] | null { + if (typeof body !== "object" || body === null) { + return null; + } + const record = body as Record; + if (record.t !== "hosts" || !Array.isArray(record.items)) { + return null; + } + const items: OnlineHost[] = []; + for (const item of record.items) { + if (typeof item !== "object" || item === null) { + continue; + } + const row = item as Record; + if (typeof row.host_id === "string" && typeof row.label === "string") { + items.push({ host_id: row.host_id, label: row.label }); + } + } + return items; +} + +export class RelaySocket { + private ws: WebSocket | null = null; + private pending: Promise | null = null; + private readonly frameHandlers = new Set<(frame: RelayFrame) => void>(); + private readonly closeHandlers = new Set<(ev: CloseEvent) => void>(); + + constructor( + private readonly url: string, + private readonly createWs: WebSocketFactory = (openUrl) => new WebSocket(openUrl), + ) {} + + get connected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + onFrame(handler: (frame: RelayFrame) => void): () => void { + this.frameHandlers.add(handler); + return () => { + this.frameHandlers.delete(handler); + }; + } + + onClose(handler: (ev: CloseEvent) => void): () => void { + this.closeHandlers.add(handler); + return () => { + this.closeHandlers.delete(handler); + }; + } + + // Idempotent: resolves at once when already open, joins a pending attempt. + connect(): Promise { + if (this.connected) { + return Promise.resolve(); + } + if (this.pending) { + return this.pending; + } + this.pending = new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (!settled) { + settled = true; + reject(new Error("Could not reach the relay")); + } + }; + const ws = this.createWs(this.url); + this.ws = ws; + ws.onopen = () => { + settled = true; + resolve(); + }; + ws.onerror = () => fail(); + ws.onclose = (ev) => { + if (this.ws === ws) { + this.ws = null; + } + fail(); + for (const handler of this.closeHandlers) { + handler(ev); + } + }; + ws.onmessage = (ev: MessageEvent) => { + const frame = parseFrame(ev.data); + if (frame) { + for (const handler of this.frameHandlers) { + handler(frame); + } + } + }; + }).finally(() => { + this.pending = null; + }); + return this.pending; + } + + send(to: string, body: unknown): void { + if (!this.connected || !this.ws) { + throw new Error("Relay is not connected"); + } + this.ws.send(JSON.stringify({ to, body })); + } + + trySend(to: string, body: unknown): boolean { + try { + this.send(to, body); + return true; + } catch { + return false; + } + } + + close(): void { + this.ws?.close(); + this.ws = null; + } +} diff --git a/apps/remote/web/src/store.ts b/apps/remote/web/src/store.ts new file mode 100644 index 0000000..fd67912 --- /dev/null +++ b/apps/remote/web/src/store.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// IndexedDB persistence for the Canopy remote web app: the non-extractable +// device key (`keys` store) and paired hosts (`hosts` store). + +import { b64decode, idFromSpki } from "./crypto.js"; +import type { DeviceKey } from "./crypto.js"; + +export const DB_NAME = "canopy-remote"; +export const KEYS_STORE = "keys"; +export const HOSTS_STORE = "hosts"; +const DEVICE_ROW_ID = "device"; + +export interface HostRecord { + host_id: string; + host_spki: string; + label: string; +} + +interface DeviceRow { + id: string; + privateKey: CryptoKey; + spkiB64: string; + deviceId: string; +} + +function idb(): IDBFactory { + return globalThis.indexedDB; +} + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed")); + }); +} + +function transactionDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }); +} + +export function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = idb().open(DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(KEYS_STORE)) { + db.createObjectStore(KEYS_STORE, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(HOSTS_STORE)) { + db.createObjectStore(HOSTS_STORE, { keyPath: "host_id" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("Could not open IndexedDB")); + }); +} + +async function withStore( + store: string, + mode: IDBTransactionMode, + run: (s: IDBObjectStore) => Promise | T, +): Promise { + const db = openDb(); + const database = await db; + try { + const tx = database.transaction(store, mode); + const result = await run(tx.objectStore(store)); + if (mode !== "readonly") { + await transactionDone(tx); + } + return result; + } finally { + database.close(); + } +} + +export async function loadDeviceKey(): Promise { + const row = await withStore(KEYS_STORE, "readonly", (s) => + request(s.get(DEVICE_ROW_ID) as IDBRequest), + ); + if (!row) { + return null; + } + const spki = b64decode(row.spkiB64); + return { privateKey: row.privateKey, spki, spkiB64: row.spkiB64, deviceId: row.deviceId }; +} + +export async function saveDeviceKey(device: DeviceKey): Promise { + const row: DeviceRow = { + id: DEVICE_ROW_ID, + privateKey: device.privateKey, + spkiB64: device.spkiB64, + deviceId: device.deviceId, + }; + await withStore(KEYS_STORE, "readwrite", (s) => { + s.put(row); + }); +} + +// Recompute the id from the stored SPKI so a stale row can never disagree +// with the key it carries. +export async function refreshDeviceId(device: DeviceKey): Promise { + const deviceId = await idFromSpki(device.spki); + if (deviceId === device.deviceId) { + return device; + } + const fixed = { ...device, deviceId }; + await saveDeviceKey(fixed); + return fixed; +} + +export async function saveHost(host: HostRecord): Promise { + await withStore(HOSTS_STORE, "readwrite", (s) => { + s.put(host); + }); +} + +export async function getHost(hostId: string): Promise { + const row = await withStore(HOSTS_STORE, "readonly", (s) => + request(s.get(hostId) as IDBRequest), + ); + return row ?? null; +} + +export async function listHosts(): Promise { + return withStore(HOSTS_STORE, "readonly", (s) => + request(s.getAll() as IDBRequest), + ); +} + +export async function deleteHost(hostId: string): Promise { + await withStore(HOSTS_STORE, "readwrite", (s) => { + s.delete(hostId); + }); +} diff --git a/apps/remote/web/src/ui.ts b/apps/remote/web/src/ui.ts new file mode 100644 index 0000000..423cdaa --- /dev/null +++ b/apps/remote/web/src/ui.ts @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +// Pair form, host picker, and connection state. Vanilla DOM, no framework. + +import { defaultDeviceLabel } from "./pairing.js"; +import { connectFailMessage } from "./peer.js"; +import type { OnlineHost } from "./relay.js"; +import type { HostRecord } from "./store.js"; + +export interface PairSubmit { + hostId: string; + code: string; + label: string; +} + +export interface UiCallbacks { + onPair: (submit: PairSubmit) => void; + onConnect: (hostId: string) => void; + onDisconnect: () => void; + onRetry: () => void; + onPairAgain: () => void; +} + +export interface AppUi { + setOnlineHosts: (hosts: OnlineHost[]) => void; + setPairedHosts: (hosts: HostRecord[]) => void; + showPair: (prefillCode?: string) => void; + showHosts: () => void; + showSession: (hostLabel: string) => void; + setPairError: (message: string | null) => void; + setPairBusy: (busy: boolean) => void; + setConnectionState: (state: string) => void; +} + +// `/pair#`: take the code from the fragment, then remove the fragment +// at once so the code never lingers in the URL. +export function consumePairFragment(): string | null { + const hash = globalThis.location.hash; + if (!hash || hash.length < 2) { + return null; + } + const code = decodeURIComponent(hash.slice(1)); + globalThis.history.replaceState( + null, + "", + globalThis.location.pathname + globalThis.location.search, + ); + return code; +} + +function esc(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +type View = "pair" | "hosts" | "session"; + +interface PairDraft { + code: string; + hostId: string; + label: string; +} + +export function createApp(root: HTMLElement, callbacks: UiCallbacks): AppUi { + let view: View = "pair"; + let online: OnlineHost[] = []; + let paired: HostRecord[] = []; + let draft: PairDraft = { code: "", hostId: "", label: "" }; + let pairError: string | null = null; + let pairBusy = false; + let sessionLabel = ""; + let connectionState = "connecting"; + + const snapshotDraft = () => { + const code = root.querySelector("#pair-code"); + const host = root.querySelector("#pair-host"); + const label = root.querySelector("#pair-label"); + if (code) { + draft = { code: code.value, hostId: host?.value ?? "", label: label?.value ?? "" }; + } + }; + + const hostOptions = (): string => { + if (online.length === 0) { + return ``; + } + if (online.length === 1 && online[0]) { + return ``; + } + const options = online.map( + (host) => + ``, + ); + const placeholder = draft.hostId === "" ? " selected" : ""; + return [``, ...options].join(""); + }; + + const renderPair = (): string => ` +

    Pair this browser

    +

    Enter the pairing code shown by canopy.

    +
    + + + + + +
    +

    Sign in with GitHub

    `; + + const renderHosts = (): string => { + const rows = paired + .map( + (host) => `
  • ${esc(host.label)} +
  • `, + ) + .join(""); + return ` +

    Canopy remote

    +
      ${rows}
    +

    `; + }; + + const renderSession = (): string => { + const failed = connectionState.startsWith("failed:"); + const unpaired = connectionState === `failed: ${connectFailMessage("unknown_device")}`; + return ` +

    ${esc(sessionLabel)}

    +

    ${esc(connectionState)}

    +

    The session view arrives with the next update.

    +

    + ${failed ? ` ` : ""} + ${unpaired ? ` ` : ""} + +

    `; + }; + + const render = () => { + if (view === "pair") { + root.innerHTML = renderPair(); + const form = root.querySelector("#pair-form"); + form?.addEventListener("submit", (ev) => { + ev.preventDefault(); + snapshotDraft(); + if (online.length === 0 || draft.hostId === "") { + pairError = online.length === 0 ? "No hosts online" : "Choose a host"; + render(); + return; + } + if (draft.code.trim() === "") { + pairError = "Enter the pairing code"; + render(); + return; + } + pairError = null; + callbacks.onPair({ hostId: draft.hostId, code: draft.code, label: draft.label }); + }); + return; + } + if (view === "hosts") { + root.innerHTML = renderHosts(); + for (const button of root.querySelectorAll("[data-connect]")) { + button.addEventListener("click", () => { + const hostId = button.getAttribute("data-connect"); + if (hostId) { + callbacks.onConnect(hostId); + } + }); + } + root.querySelector("#pair-new")?.addEventListener("click", () => callbacks.onPairAgain()); + return; + } + root.innerHTML = renderSession(); + root.querySelector("#conn-disconnect")?.addEventListener("click", () => callbacks.onDisconnect()); + root.querySelector("#conn-retry")?.addEventListener("click", () => callbacks.onRetry()); + root.querySelector("#conn-pair-again")?.addEventListener("click", () => callbacks.onPairAgain()); + }; + + return { + setOnlineHosts(hosts: OnlineHost[]): void { + online = hosts; + if (view === "pair") { + snapshotDraft(); + if (online.length === 1 && online[0] && draft.hostId === "") { + draft.hostId = online[0].host_id; + } + render(); + } + }, + setPairedHosts(hosts: HostRecord[]): void { + paired = hosts; + if (view === "hosts") { + render(); + } + }, + showPair(prefillCode?: string): void { + view = "pair"; + pairError = null; + pairBusy = false; + draft = { + code: prefillCode ?? draft.code, + hostId: online.length === 1 && online[0] ? online[0].host_id : draft.hostId, + label: draft.label === "" ? defaultDeviceLabel() : draft.label, + }; + render(); + }, + showHosts(): void { + view = "hosts"; + render(); + }, + showSession(hostLabel: string): void { + view = "session"; + sessionLabel = hostLabel; + connectionState = "connecting"; + render(); + }, + setPairError(message: string | null): void { + pairError = message; + if (view === "pair") { + snapshotDraft(); + render(); + } + }, + setPairBusy(busy: boolean): void { + pairBusy = busy; + if (view === "pair") { + snapshotDraft(); + render(); + } + }, + setConnectionState(state: string): void { + connectionState = state; + if (view === "session") { + render(); + } + }, + }; +} diff --git a/apps/remote/web/test/channel.test.ts b/apps/remote/web/test/channel.test.ts new file mode 100644 index 0000000..4022c0d --- /dev/null +++ b/apps/remote/web/test/channel.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { createChannelHandler } from "../src/channel.js"; +import type { JoinedSnapshot } from "../src/channel.js"; + +function setup() { + const snapshots: JoinedSnapshot[] = []; + const frames: unknown[] = []; + const channel = createChannelHandler({ + onSnapshot: (snapshot) => snapshots.push(snapshot), + onFrame: (frame) => frames.push(frame), + }); + return { channel, snapshots, frames }; +} + +describe("createChannelHandler", () => { + it("joins snapshot plus snapshot_more chunks into one snapshot", () => { + const { channel, snapshots, frames } = setup(); + channel.push(JSON.stringify({ t: "snapshot", id: "s1", cols: 80, rows: 24, seq: 7, data: "aaa" })); + expect(snapshots).toHaveLength(0); + channel.push(JSON.stringify({ t: "snapshot_more", id: "s1", data: "bbb", last: false })); + expect(snapshots).toHaveLength(0); + channel.push(JSON.stringify({ t: "snapshot_more", id: "s1", data: "ccc", last: true })); + expect(snapshots).toEqual([{ id: "s1", cols: 80, rows: 24, seq: 7, data: "aaabbbccc" }]); + expect(frames).toHaveLength(0); + }); + + it("ignores chunks for an unknown snapshot", () => { + const { channel, snapshots, frames } = setup(); + channel.push(JSON.stringify({ t: "snapshot_more", id: "nope", data: "x", last: true })); + expect(snapshots).toHaveLength(0); + expect(frames).toHaveLength(0); + }); + + it("lets a fresh snapshot supersede a partial one", () => { + const { channel, snapshots } = setup(); + channel.push(JSON.stringify({ t: "snapshot", id: "s1", cols: 80, rows: 24, seq: 7, data: "aaa" })); + channel.push(JSON.stringify({ t: "snapshot", id: "s1", cols: 80, rows: 24, seq: 8, data: "zzz" })); + channel.push(JSON.stringify({ t: "snapshot_more", id: "s1", data: "!", last: true })); + expect(snapshots).toEqual([{ id: "s1", cols: 80, rows: 24, seq: 8, data: "zzz!" }]); + }); + + it("passes other frames through and ignores invalid JSON", () => { + const { channel, snapshots, frames } = setup(); + channel.push(JSON.stringify({ t: "out", id: "s1", seq: 9, data: "eA==" })); + channel.push("not json"); + expect(frames).toEqual([{ t: "out", id: "s1", seq: 9, data: "eA==" }]); + expect(snapshots).toHaveLength(0); + }); +}); diff --git a/apps/remote/web/test/crypto.test.ts b/apps/remote/web/test/crypto.test.ts new file mode 100644 index 0000000..85b29b6 --- /dev/null +++ b/apps/remote/web/test/crypto.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Contract tests: the d4eb8558 vectors plus code normalisation. + +import { beforeAll, describe, expect, it } from "vitest"; +import { + b64decode, + b64encode, + derivePairKey, + generateDeviceKey, + idFromSpki, + importSpkiVerifyKey, + normalizeCode, + pairInitMac, + pairOkMac, + sha256Hex, + verifyAnswer, +} from "../src/crypto.js"; + +import { + ANSWER_SDP, + ANSWER_SIG, + CODE, + CODE_NORMALIZED, + DEVICE_ID, + DEVICE_SPKI_B64, + HOST_ID, + HOST_SPKI_B64, + MAC_INIT, + MAC_OK, + OFFER_SIG, + nonceB, + nonceH, +} from "./vectors.js"; + +describe("normalizeCode", () => { + it("keeps a canonical code", () => { + expect(normalizeCode(CODE)).toBe(CODE_NORMALIZED); + }); + + it("uppercases and drops hyphens and spaces", () => { + expect(normalizeCode("7k3qh m9p2x")).toBe(CODE_NORMALIZED); + expect(normalizeCode(" 7K3QH-M9P2X ")).toBe(CODE_NORMALIZED); + }); + + it("folds I/L to 1 and O to 0", () => { + expect(normalizeCode("IIIIOOOOOO")).toBe("1111000000"); + expect(normalizeCode("llllllllll")).toBe("1111111111"); + }); + + it("rejects the wrong length", () => { + expect(() => normalizeCode("7K3QH-M9P")).toThrow("Invalid pairing code"); + expect(() => normalizeCode("7K3QH-M9P2XX")).toThrow("Invalid pairing code"); + expect(() => normalizeCode("")).toThrow("Invalid pairing code"); + }); + + it("rejects characters outside the alphabet", () => { + expect(() => normalizeCode("7K3QH-M9P2U")).toThrow("Invalid pairing code"); + expect(() => normalizeCode("7K3QH-M9P2!")).toThrow("Invalid pairing code"); + }); +}); + +describe("base64 and hashing", () => { + it("round-trips bytes with padding", () => { + const bytes = Uint8Array.from([0, 1, 2, 250, 255]); + expect(b64decode(b64encode(bytes))).toEqual(bytes); + expect(b64encode(new Uint8Array([0]))).toBe("AA=="); + }); + + it("hashes with SHA-256", async () => { + expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + }); + + it("derives the vector device and host ids from the SPKIs", async () => { + expect(await idFromSpki(b64decode(DEVICE_SPKI_B64))).toBe(DEVICE_ID); + expect(await idFromSpki(b64decode(HOST_SPKI_B64))).toBe(HOST_ID); + }); +}); + +describe("pairing MACs", () => { + let pairKey: CryptoKey; + + beforeAll(async () => { + pairKey = await derivePairKey(CODE_NORMALIZED, HOST_ID); + }); + + it("matches mac_init", async () => { + await expect(pairInitMac(pairKey, DEVICE_SPKI_B64, b64encode(nonceB()))).resolves.toBe(MAC_INIT); + }); + + it("matches mac_ok", async () => { + await expect( + pairOkMac(pairKey, HOST_SPKI_B64, DEVICE_SPKI_B64, b64encode(nonceB()), b64encode(nonceH())), + ).resolves.toBe(MAC_OK); + }); + + it("changes when any input changes", async () => { + const other = await pairInitMac(pairKey, DEVICE_SPKI_B64, b64encode(nonceH())); + expect(other).not.toBe(MAC_INIT); + }); +}); + +describe("answer signatures", () => { + it("verifies the vector answer signature with the host SPKI", async () => { + const hostKey = await importSpkiVerifyKey(b64decode(HOST_SPKI_B64)); + await expect(verifyAnswer(hostKey, DEVICE_ID, OFFER_SIG, ANSWER_SDP, ANSWER_SIG)).resolves.toBe( + true, + ); + }); + + it("refuses a tampered answer signature", async () => { + const hostKey = await importSpkiVerifyKey(b64decode(HOST_SPKI_B64)); + const forged = `${ANSWER_SIG.slice(0, -4)}MA==`; + await expect(verifyAnswer(hostKey, DEVICE_ID, OFFER_SIG, ANSWER_SDP, forged)).resolves.toBe( + false, + ); + }); +}); + +describe("generateDeviceKey", () => { + it("creates a non-extractable signing key with a matching id", async () => { + const device = await generateDeviceKey(); + expect(device.privateKey.extractable).toBe(false); + expect(device.deviceId).toBe(await idFromSpki(device.spki)); + expect(b64encode(device.spki)).toBe(device.spkiB64); + }); +}); diff --git a/apps/remote/web/test/fakes.ts b/apps/remote/web/test/fakes.ts new file mode 100644 index 0000000..bbf5da7 --- /dev/null +++ b/apps/remote/web/test/fakes.ts @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: Apache-2.0 +// Test fakes: relay WebSocket, WebRTC peer connection, TURN fetch, page +// visibility. The relay and the network are always faked in tests. + +import { b64encode, idFromSpki } from "../src/crypto.js"; +import { RelaySocket } from "../src/relay.js"; +import type { HostRecord } from "../src/store.js"; + +export interface SentEnvelope { + to: string; + body: Record; +} + +export class FakeWebSocket { + readonly url: string; + readyState = 0; + onopen: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + readonly sent: string[] = []; + + constructor(url: string) { + this.url = url; + } + + open(): void { + this.readyState = 1; + this.onopen?.(new Event("open")); + } + + receive(from: string, body: unknown): void { + this.onmessage?.(new MessageEvent("message", { data: JSON.stringify({ from, body }) })); + } + + receiveRaw(data: string): void { + this.onmessage?.(new MessageEvent("message", { data })); + } + + fail(): void { + this.onerror?.(new Event("error")); + this.close(); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = 3; + this.onclose?.(new CloseEvent("close")); + } + + envelopes(): SentEnvelope[] { + return this.sent.map((raw) => JSON.parse(raw) as SentEnvelope); + } +} + +export interface RelayHarness { + relay: RelaySocket; + sockets: FakeWebSocket[]; + latest: () => FakeWebSocket; +} + +export function createRelayHarness(): RelayHarness { + const sockets: FakeWebSocket[] = []; + const relay = new RelaySocket("wss://test.invalid/ws/device", (url) => { + const ws = new FakeWebSocket(url); + sockets.push(ws); + return ws as unknown as WebSocket; + }); + return { + relay, + sockets, + latest: () => { + const ws = sockets[sockets.length - 1]; + if (!ws) { + throw new Error("no socket created yet"); + } + return ws; + }, + }; +} + +export async function connectRelay(harness: RelayHarness): Promise { + const opened = harness.relay.connect(); + harness.latest().open(); + await opened; + return harness.latest(); +} + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +export async function waitForSend(ws: FakeWebSocket): Promise { + for (let i = 0; i < 200 && ws.sent.length === 0; i += 1) { + await flush(); + } + if (ws.sent.length === 0) { + throw new Error("timed out waiting for a relay send"); + } +} + +export class FakeDataChannel { + readonly label: string; + readonly ordered: boolean; + readyState = "connecting"; + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + readonly sent: string[] = []; + + constructor(label: string, options?: { ordered?: boolean }) { + this.label = label; + this.ordered = options?.ordered ?? false; + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.readyState === "closed") { + return; + } + this.readyState = "closed"; + this.onclose?.(new Event("close")); + } + + remoteOpen(): void { + this.readyState = "open"; + this.onopen?.(new Event("open")); + } + + deliver(data: string): void { + this.onmessage?.(new MessageEvent("message", { data })); + } +} + +export class FakePeerConnection { + readonly config: RTCConfiguration; + localDescription: { type: string; sdp?: string } | null = null; + remoteDescription: { type: string; sdp?: string } | null = null; + connectionState = "new"; + onconnectionstatechange: ((ev: Event) => void) | null = null; + onicecandidate: ((ev: { candidate: { toJSON: () => unknown } | null }) => void) | null = null; + readonly channels: FakeDataChannel[] = []; + readonly addedCandidates: unknown[] = []; + readonly offerSdp: string; + closed = false; + + constructor(config: RTCConfiguration, offerSdp?: string) { + this.config = config; + this.offerSdp = offerSdp ?? "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\n"; + } + + createDataChannel(label: string, options?: { ordered?: boolean }): RTCDataChannel { + const channel = new FakeDataChannel(label, options); + this.channels.push(channel); + return channel as unknown as RTCDataChannel; + } + + async createOffer(): Promise<{ type: string; sdp: string }> { + return { type: "offer", sdp: this.offerSdp }; + } + + async setLocalDescription(desc: { type: string; sdp?: string }): Promise { + this.localDescription = desc; + } + + async setRemoteDescription(desc: { type: string; sdp?: string }): Promise { + this.remoteDescription = desc; + } + + async addIceCandidate(init: unknown): Promise { + this.addedCandidates.push(init); + } + + close(): void { + this.closed = true; + for (const channel of this.channels) { + channel.close(); + } + } + + firstChannel(): FakeDataChannel { + const channel = this.channels[0]; + if (!channel) { + throw new Error("no data channel created yet"); + } + return channel; + } + + emitIceCandidate(init: Record): void { + this.onicecandidate?.({ candidate: { toJSON: () => init } }); + } + + setConnectionState(state: string): void { + this.connectionState = state; + this.onconnectionstatechange?.(new Event("connectionstatechange")); + } +} + +export interface PeerHarness { + peers: FakePeerConnection[]; + factory: (config: RTCConfiguration) => RTCPeerConnection; + latest: () => FakePeerConnection; +} + +export function createPeerHarness(offerSdp?: string): PeerHarness { + const peers: FakePeerConnection[] = []; + return { + peers, + factory: (config) => { + const pc = new FakePeerConnection(config, offerSdp); + peers.push(pc); + return pc as unknown as RTCPeerConnection; + }, + latest: () => { + const pc = peers[peers.length - 1]; + if (!pc) { + throw new Error("no peer connection created yet"); + } + return pc; + }, + }; +} + +export function createTurnFetcher(ok = true): { + calls: string[]; + fetchTurn: (url: string) => Promise<{ ok: boolean; json: () => Promise }>; +} { + const calls: string[] = []; + return { + calls, + fetchTurn: async (url: string) => { + calls.push(url); + return { + ok, + json: async () => ({ iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }] }), + }; + }, + }; +} + +export interface TestHost { + record: HostRecord; + privateKey: CryptoKey; +} + +export async function createTestHost(label = "test host"): Promise { + const pair = await globalThis.crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const spki = new Uint8Array(await globalThis.crypto.subtle.exportKey("spki", pair.publicKey)); + return { + record: { host_id: await idFromSpki(spki), host_spki: b64encode(spki), label }, + privateKey: pair.privateKey, + }; +} + +export async function signTestAnswer( + privateKey: CryptoKey, + deviceId: string, + offerSig: string, + sdp: string, +): Promise { + const sig = await globalThis.crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + new TextEncoder().encode(`canopy-remote/v1/answer\n${deviceId}\n${offerSig}\n${sdp}`), + ); + return b64encode(new Uint8Array(sig)); +} + +export interface VisibilityHarness { + visibility: { isVisible: () => boolean; onChange: (callback: () => void) => () => void }; + setVisible: (visible: boolean) => void; +} + +export function createVisibilityHarness(initial = true): VisibilityHarness { + let visible = initial; + const listeners = new Set<() => void>(); + return { + visibility: { + isVisible: () => visible, + onChange: (callback: () => void) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }, + }, + setVisible: (next: boolean) => { + visible = next; + for (const listener of [...listeners]) { + listener(); + } + }, + }; +} diff --git a/apps/remote/web/test/pairing.test.ts b/apps/remote/web/test/pairing.test.ts new file mode 100644 index 0000000..5ad6c08 --- /dev/null +++ b/apps/remote/web/test/pairing.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +import { beforeAll, describe, expect, it } from "vitest"; +import { b64decode, b64encode, derivePairKey, generateDeviceKey, pairOkMac } from "../src/crypto.js"; +import type { DeviceKey } from "../src/crypto.js"; +import { defaultDeviceLabel, runPairing } from "../src/pairing.js"; +import type { PairingDeps } from "../src/pairing.js"; +import type { HostRecord } from "../src/store.js"; +import { connectRelay, createRelayHarness, flush, waitForSend } from "./fakes.js"; +import type { FakeWebSocket, RelayHarness } from "./fakes.js"; +import { + CODE, + CODE_NORMALIZED, + DEVICE_ID, + DEVICE_SPKI_B64, + HOST_ID, + HOST_SPKI_B64, + MAC_INIT, + MAC_OK, + nonceB, + nonceH, +} from "./vectors.js"; + +let vectorDevice: DeviceKey; + +beforeAll(async () => { + const real = await generateDeviceKey(); + vectorDevice = { + ...real, + spki: b64decode(DEVICE_SPKI_B64), + spkiB64: DEVICE_SPKI_B64, + deviceId: DEVICE_ID, + }; +}); + +interface PairingSetup { + harness: RelayHarness; + ws: FakeWebSocket; + saved: HostRecord[]; +} + +async function setupPairing(): Promise { + const harness = createRelayHarness(); + const ws = await connectRelay(harness); + return { harness, ws, saved: [] }; +} + +function pairOptions(setup: PairingSetup, overrides?: Partial) { + return { + relay: setup.harness.relay, + hostId: HOST_ID, + hostLabel: "canopy", + code: CODE, + deviceLabel: "Test browser", + device: vectorDevice, + saveHost: async (host: HostRecord) => { + setup.saved.push(host); + }, + randomNonce: nonceB, + ...overrides, + }; +} + +const pairOkBody = () => ({ + t: "pair_ok", + host_spki: HOST_SPKI_B64, + nonce_h: b64encode(nonceH()), + mac: MAC_OK, +}); + +describe("runPairing", () => { + it("sends pair_init with mac_init and stores the host on the mac_ok pair_ok", async () => { + const setup = await setupPairing(); + const pending = runPairing(pairOptions(setup)); + await waitForSend(setup.ws); + const [envelope] = setup.ws.envelopes(); + expect(envelope?.to).toBe(`h:${HOST_ID}`); + expect(envelope?.body).toEqual({ + t: "pair_init", + device_spki: DEVICE_SPKI_B64, + device_label: "Test browser", + nonce_b: b64encode(nonceB()), + mac: MAC_INIT, + }); + // The code never leaves the page. + expect(setup.ws.sent.join("\n")).not.toContain("7K3QH"); + expect(setup.ws.sent.join("\n")).not.toContain(CODE_NORMALIZED); + setup.ws.receive(`h:${HOST_ID}`, pairOkBody()); + await expect(pending).resolves.toEqual({ + host_id: HOST_ID, + host_spki: HOST_SPKI_B64, + label: "canopy", + }); + expect(setup.saved).toEqual([{ host_id: HOST_ID, host_spki: HOST_SPKI_B64, label: "canopy" }]); + }); + + it.each([ + ["no_code", "No pairing code is open on this host"], + ["expired", "The code expired; create a new one in canopy"], + ["bad_code", "Wrong code"], + ["weird", "Pairing failed (weird)"], + ])("maps pair_fail %s", async (reason, message) => { + const setup = await setupPairing(); + const pending = runPairing(pairOptions(setup)); + const assertion = expect(pending).rejects.toThrow(message); + await waitForSend(setup.ws); + setup.ws.receive(`h:${HOST_ID}`, { t: "pair_fail", reason }); + await assertion; + expect(setup.saved).toHaveLength(0); + }); + + it("refuses a pair_ok with a bad mac", async () => { + const setup = await setupPairing(); + const pending = runPairing(pairOptions(setup)); + const assertion = expect(pending).rejects.toThrow("Host identity check failed"); + await waitForSend(setup.ws); + setup.ws.receive(`h:${HOST_ID}`, { ...pairOkBody(), mac: MAC_INIT }); + await assertion; + expect(setup.saved).toHaveLength(0); + }); + + it("refuses a host_spki that does not match the host id", async () => { + const setup = await setupPairing(); + const key = await derivePairKey(CODE_NORMALIZED, HOST_ID); + const mac = await pairOkMac( + key, + DEVICE_SPKI_B64, + DEVICE_SPKI_B64, + b64encode(nonceB()), + b64encode(nonceH()), + ); + const pending = runPairing(pairOptions(setup)); + const assertion = expect(pending).rejects.toThrow("Host identity check failed"); + await waitForSend(setup.ws); + setup.ws.receive(`h:${HOST_ID}`, { + t: "pair_ok", + host_spki: DEVICE_SPKI_B64, + nonce_h: b64encode(nonceH()), + mac, + }); + await assertion; + }); + + it("rejects a malformed code without sending", async () => { + const setup = await setupPairing(); + await expect(runPairing(pairOptions(setup, { code: "bogus" }))).rejects.toThrow( + "Invalid pairing code", + ); + expect(setup.ws.sent).toHaveLength(0); + }); + + it("times out when the host never answers", async () => { + const setup = await setupPairing(); + await expect(runPairing(pairOptions(setup, { timeoutMs: 1 }))).rejects.toThrow( + "Pairing timed out", + ); + }); + + it("ignores frames that are not the host reply", async () => { + const setup = await setupPairing(); + const pending = runPairing(pairOptions(setup)); + let settled = false; + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await waitForSend(setup.ws); + setup.ws.receive("relay", { t: "hosts", items: [] }); + setup.ws.receive("h:other", pairOkBody()); + await flush(); + await flush(); + expect(settled).toBe(false); + setup.ws.receive(`h:${HOST_ID}`, pairOkBody()); + await expect(pending).resolves.toBeDefined(); + }); +}); + +describe("defaultDeviceLabel", () => { + it("looks like on ", () => { + expect(defaultDeviceLabel()).toContain(" on "); + }); +}); diff --git a/apps/remote/web/test/peer.test.ts b/apps/remote/web/test/peer.test.ts new file mode 100644 index 0000000..91fc362 --- /dev/null +++ b/apps/remote/web/test/peer.test.ts @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { b64decode, generateDeviceKey, importSpkiVerifyKey } from "../src/crypto.js"; +import type { DeviceKey } from "../src/crypto.js"; +import { HostPeer } from "../src/peer.js"; +import type { PeerEvent } from "../src/peer.js"; +import { + connectRelay, + createPeerHarness, + createRelayHarness, + createTestHost, + createTurnFetcher, + flush, + signTestAnswer, + waitForSend, +} from "./fakes.js"; +import type { FakeWebSocket, PeerHarness, RelayHarness, TestHost } from "./fakes.js"; + +const ANSWER_SDP = "v=0\r\no=- 9 9 IN IP4 127.0.0.1\r\n"; + +interface PeerSetup { + relayHarness: RelayHarness; + ws: FakeWebSocket; + peerHarness: PeerHarness; + device: DeviceKey; + host: TestHost; + events: PeerEvent[]; + peer: HostPeer; +} + +async function setupPeer(turnOk = true): Promise { + const relayHarness = createRelayHarness(); + const ws = await connectRelay(relayHarness); + const peerHarness = createPeerHarness(); + const turn = createTurnFetcher(turnOk); + const device = await generateDeviceKey(); + const host = await createTestHost(); + const events: PeerEvent[] = []; + const peer = new HostPeer({ + relay: relayHarness.relay, + host: host.record, + device, + fetchTurn: turn.fetchTurn, + createConnection: peerHarness.factory, + nowMs: () => 1793100000_000, + }); + peer.onEvent((ev) => events.push(ev)); + return { relayHarness, ws, peerHarness, device, host, events, peer }; +} + +function offerBody(setup: PeerSetup): { device_id: string; ts: string; sdp: string; sig: string } { + const offer = setup.ws.envelopes().find((e) => e.body.t === "offer"); + if (!offer || offer.to !== `h:${setup.host.record.host_id}`) { + throw new Error("no offer sent to the host"); + } + return offer.body as unknown as { device_id: string; ts: string; sdp: string; sig: string }; +} + +async function waitForRemoteDescription(setup: PeerSetup): Promise { + for (let i = 0; i < 100 && !setup.peerHarness.latest().remoteDescription; i += 1) { + await flush(); + } + if (!setup.peerHarness.latest().remoteDescription) { + throw new Error("remote description was never set"); + } +} + +async function answerHandshake(setup: PeerSetup, sdp: string = ANSWER_SDP): Promise { + const offer = offerBody(setup); + const sig = await signTestAnswer( + setup.host.privateKey, + setup.device.deviceId, + offer.sig, + sdp, + ); + setup.ws.receive(`h:${setup.host.record.host_id}`, { t: "answer", sdp, sig }); + await waitForRemoteDescription(setup); +} + +async function setupAndOpen(): Promise { + const setup = await setupPeer(); + const pending = setup.peer.connect(); + await waitForSend(setup.ws); + await answerHandshake(setup); + setup.peerHarness.latest().firstChannel().remoteOpen(); + await pending; + return setup; +} + +async function offerSignatureValid( + device: DeviceKey, + hostId: string, + ts: string, + sdp: string, + sig: string, +): Promise { + const key = await importSpkiVerifyKey(device.spki); + return globalThis.crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + new Uint8Array(b64decode(sig)), + new TextEncoder().encode(`canopy-remote/v1/offer\n${hostId}\n${ts}\n${sdp}`), + ); +} + +describe("HostPeer", () => { + it("sends a signed offer and opens the channel on a valid answer", async () => { + const setup = await setupPeer(); + const pending = setup.peer.connect(); + await waitForSend(setup.ws); + + const offer = offerBody(setup); + expect(offer.device_id).toBe(setup.device.deviceId); + expect(offer.ts).toBe("1793100000"); + expect(offer.sdp).toBe(setup.peerHarness.latest().offerSdp); + await expect( + offerSignatureValid(setup.device, setup.host.record.host_id, offer.ts, offer.sdp, offer.sig), + ).resolves.toBe(true); + + const pc = setup.peerHarness.latest(); + expect(pc.config.iceServers).toEqual([{ urls: "stun:stun.cloudflare.com:3478" }]); + expect(pc.firstChannel().label).toBe("canopy-session-v1"); + expect(pc.firstChannel().ordered).toBe(true); + + // ICE before the answer is queued, not dropped. + setup.ws.receive(`h:${setup.host.record.host_id}`, { + t: "ice", + candidate: { candidate: "c1", sdpMid: "0", sdpMLineIndex: 0 }, + }); + await flush(); + expect(pc.addedCandidates).toHaveLength(0); + + // An answer from another sender is ignored. + setup.ws.receive("h:other", { t: "answer", sdp: ANSWER_SDP, sig: "bogus" }); + await flush(); + expect(pc.remoteDescription).toBeNull(); + + await answerHandshake(setup); + expect(pc.remoteDescription).toEqual({ type: "answer", sdp: ANSWER_SDP }); + expect(pc.addedCandidates).toEqual([ + { candidate: "c1", sdpMid: "0", sdpMLineIndex: 0 }, + ]); + + pc.firstChannel().remoteOpen(); + await pending; + expect(setup.events).toContainEqual({ type: "open" }); + + pc.firstChannel().deliver(`{"t":"out"}`); + expect(setup.events).toContainEqual({ type: "message", data: `{"t":"out"}` }); + }); + + it("refuses a forged answer and closes", async () => { + const setup = await setupPeer(); + const pending = setup.peer.connect(); + await waitForSend(setup.ws); + const offer = offerBody(setup); + const forged = `${offer.sig.slice(0, -4)}MA==`; + setup.ws.receive(`h:${setup.host.record.host_id}`, { t: "answer", sdp: ANSWER_SDP, sig: forged }); + await expect(pending).rejects.toThrow("Host identity check failed"); + expect(setup.events).toContainEqual({ type: "failed", reason: "Host identity check failed" }); + expect(setup.peerHarness.latest().closed).toBe(true); + }); + + it.each([ + ["unknown_device", "This browser is no longer paired"], + ["stale", "Clock skew: check this device's time"], + ["busy", "Too many devices connected"], + ])("fails with connect_fail %s", async (reason, message) => { + const setup = await setupPeer(); + const pending = setup.peer.connect(); + const assertion = expect(pending).rejects.toThrow(message); + await waitForSend(setup.ws); + setup.ws.receive(`h:${setup.host.record.host_id}`, { t: "connect_fail", reason }); + await assertion; + expect(setup.events).toContainEqual({ type: "failed", reason: message }); + }); + + it("trickles local candidates", async () => { + const setup = await setupPeer(); + const pending = setup.peer.connect(); + await waitForSend(setup.ws); + setup.peerHarness.latest().emitIceCandidate({ candidate: "c2", sdpMid: "0", sdpMLineIndex: 0 }); + const ice = setup.ws.envelopes().find((e) => e.body.t === "ice"); + expect(ice?.to).toBe(`h:${setup.host.record.host_id}`); + expect(ice?.body).toEqual({ + t: "ice", + candidate: { candidate: "c2", sdpMid: "0", sdpMLineIndex: 0 }, + }); + setup.peer.close(); + await expect(pending).rejects.toThrow("Connection closed"); + }); + + it("sends bye and closes on close", async () => { + const setup = await setupAndOpen(); + setup.peer.close(); + const byes = setup.ws.envelopes().filter((e) => e.body.t === "bye"); + expect(byes).toHaveLength(1); + expect(byes[0]?.to).toBe(`h:${setup.host.record.host_id}`); + expect(setup.peerHarness.latest().closed).toBe(true); + }); + + it("emits closed when the channel drops", async () => { + const setup = await setupAndOpen(); + setup.peerHarness.latest().firstChannel().close(); + expect(setup.events).toContainEqual({ type: "closed" }); + }); + + it("fails when TURN credentials cannot load", async () => { + const setup = await setupPeer(false); + await expect(setup.peer.connect()).rejects.toThrow("Could not load TURN credentials"); + }); +}); diff --git a/apps/remote/web/test/reconnect.test.ts b/apps/remote/web/test/reconnect.test.ts new file mode 100644 index 0000000..69eedbc --- /dev/null +++ b/apps/remote/web/test/reconnect.test.ts @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { generateDeviceKey } from "../src/crypto.js"; +import type { DeviceKey } from "../src/crypto.js"; +import { ConnectionManager, documentVisibility } from "../src/reconnect.js"; +import { + createPeerHarness, + createRelayHarness, + createTestHost, + createTurnFetcher, + createVisibilityHarness, + signTestAnswer, +} from "./fakes.js"; +import type { PeerHarness, RelayHarness, TestHost, VisibilityHarness } from "./fakes.js"; + +const ANSWER_SDP = "v=0\r\no=- 9 9 IN IP4 127.0.0.1\r\n"; + +// Real clock and delay, captured before any test fakes the timers. The waits +// below are bounded by real time (WebCrypto runs on the threadpool, so a +// fixed number of fake-clock pumps flakes under parallel load) while the +// fake clock itself only advances via explicit `advanceTimersByTime` calls. +const realSetTimeout = globalThis.setTimeout; +const realDateNow = Date.now; +const WAIT_MS = 4000; + +const realDelay = () => + new Promise((resolve) => { + realSetTimeout(resolve, 0); + }); + +async function pump(): Promise { + await realDelay(); + await vi.advanceTimersByTimeAsync(0); +} + +interface ManagerSetup { + relayHarness: RelayHarness; + peerHarness: PeerHarness; + visibility: VisibilityHarness; + device: DeviceKey; + host: TestHost; + states: string[]; + manager: ConnectionManager; +} + +let active: ManagerSetup | null = null; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + active?.manager.stop(); + active = null; + vi.useRealTimers(); +}); + +async function createManager(): Promise { + const relayHarness = createRelayHarness(); + const peerHarness = createPeerHarness(); + const turn = createTurnFetcher(); + const visibility = createVisibilityHarness(true); + const device = await generateDeviceKey(); + const host = await createTestHost("canopy"); + const states: string[] = []; + const manager = new ConnectionManager({ + relay: relayHarness.relay, + host: host.record, + device, + fetchTurn: turn.fetchTurn, + createConnection: peerHarness.factory, + visibility: visibility.visibility, + }); + manager.onState((state) => states.push(state)); + active = { relayHarness, peerHarness, visibility, device, host, states, manager }; + return active; +} + +async function settle(rounds = 20): Promise { + for (let i = 0; i < rounds; i += 1) { + await vi.advanceTimersByTimeAsync(0); + } +} + +function offerCount(setup: ManagerSetup): number { + return setup.relayHarness.sockets + .flatMap((socket) => socket.envelopes()) + .filter((e) => e.body.t === "offer").length; +} + +async function waitForOffers(setup: ManagerSetup, n: number): Promise { + const deadline = realDateNow() + WAIT_MS; + while (offerCount(setup) < n) { + if (realDateNow() > deadline) { + throw new Error(`only ${offerCount(setup)} offers sent, wanted ${n}`); + } + await pump(); + } +} + +async function startAndOffer(setup: ManagerSetup): Promise { + setup.manager.start(); + await settle(5); + setup.relayHarness.latest().open(); + await waitForOffers(setup, 1); +} + +async function dropChannel(setup: ManagerSetup): Promise { + setup.peerHarness.latest().firstChannel().close(); + await settle(10); +} + +async function completeHandshake(setup: ManagerSetup): Promise { + const offers = setup.relayHarness.sockets + .flatMap((socket) => socket.envelopes()) + .filter((e) => e.body.t === "offer"); + const last = offers[offers.length - 1]; + if (!last) { + throw new Error("no offer to answer"); + } + const sig = await signTestAnswer( + setup.host.privateKey, + setup.device.deviceId, + (last.body as unknown as { sig: string }).sig, + ANSWER_SDP, + ); + setup.relayHarness.latest().receive(`h:${setup.host.record.host_id}`, { + t: "answer", + sdp: ANSWER_SDP, + sig, + }); + const deadline = realDateNow() + WAIT_MS; + while (!setup.peerHarness.latest().remoteDescription) { + if (realDateNow() > deadline) { + throw new Error("remote description was never set"); + } + await pump(); + } + setup.peerHarness.latest().firstChannel().remoteOpen(); + await settle(10); +} + +const lastState = (setup: ManagerSetup): string => { + const state = setup.states[setup.states.length - 1]; + if (state === undefined) { + throw new Error("no states emitted"); + } + return state; +}; + +describe("ConnectionManager", () => { + it("retries with backoff 1, 2, 4, 8, 16, 30, 30", async () => { + const setup = await createManager(); + setup.manager.start(); + await settle(5); + setup.relayHarness.latest().open(); + const delays = [1, 2, 4, 8, 16, 30, 30]; + for (let attempt = 0; attempt < delays.length; attempt += 1) { + await waitForOffers(setup, attempt + 1); + await dropChannel(setup); + expect(lastState(setup)).toBe(`retrying in ${delays[attempt]}s`); + await vi.advanceTimersByTimeAsync((delays[attempt] ?? 30) * 1000); + expect(lastState(setup)).toBe("connecting"); + } + await waitForOffers(setup, delays.length + 1); + }); + + it("counts down retrying in Ns each second", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await dropChannel(setup); + expect(lastState(setup)).toBe("retrying in 1s"); + await vi.advanceTimersByTimeAsync(1000); + await waitForOffers(setup, 2); + await dropChannel(setup); + expect(lastState(setup)).toBe("retrying in 2s"); + await vi.advanceTimersByTimeAsync(1000); + expect(lastState(setup)).toBe("retrying in 1s"); + await vi.advanceTimersByTimeAsync(1000); + expect(lastState(setup)).toBe("connecting"); + }); + + it("pauses while hidden and retries at once on visible", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await dropChannel(setup); + expect(lastState(setup)).toBe("retrying in 1s"); + setup.visibility.setVisible(false); + await vi.advanceTimersByTimeAsync(10000); + expect(offerCount(setup)).toBe(1); + expect(lastState(setup)).toBe("retrying in 1s"); + setup.visibility.setVisible(true); + expect(lastState(setup)).toBe("connecting"); + await waitForOffers(setup, 2); + }); + + it("waits 5s on ICE disconnected before retrying", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await completeHandshake(setup); + expect(lastState(setup)).toBe("connected"); + setup.peerHarness.latest().setConnectionState("disconnected"); + await vi.advanceTimersByTimeAsync(4999); + expect(lastState(setup)).toBe("connected"); + await vi.advanceTimersByTimeAsync(1); + expect(lastState(setup)).toBe("retrying in 1s"); + }); + + it("stays connected when ICE recovers within the grace period", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await completeHandshake(setup); + setup.peerHarness.latest().setConnectionState("disconnected"); + await vi.advanceTimersByTimeAsync(4000); + setup.peerHarness.latest().setConnectionState("connected"); + await vi.advanceTimersByTimeAsync(10000); + expect(lastState(setup)).toBe("connected"); + expect(offerCount(setup)).toBe(1); + }); + + it("retries at once on ICE failed", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await completeHandshake(setup); + setup.peerHarness.latest().setConnectionState("failed"); + await settle(5); + expect(lastState(setup)).toBe("retrying in 1s"); + }); + + it.each([ + ["busy", "Too many devices connected"], + ["unknown_device", "This browser is no longer paired"], + ["stale", "Clock skew: check this device's time"], + ])("stops on connect_fail %s without retrying", async (reason, message) => { + const setup = await createManager(); + await startAndOffer(setup); + setup.relayHarness.latest().receive(`h:${setup.host.record.host_id}`, { + t: "connect_fail", + reason, + }); + await settle(10); + expect(lastState(setup)).toBe(`failed: ${message}`); + await vi.advanceTimersByTimeAsync(60000); + expect(offerCount(setup)).toBe(1); + }); + + it("retries on demand after a failure", async () => { + const setup = await createManager(); + await startAndOffer(setup); + setup.relayHarness.latest().receive(`h:${setup.host.record.host_id}`, { + t: "connect_fail", + reason: "busy", + }); + await settle(10); + expect(lastState(setup)).toBe("failed: Too many devices connected"); + setup.manager.retryNow(); + expect(lastState(setup)).toBe("connecting"); + await waitForOffers(setup, 2); + }); + + it("stop cancels a pending retry", async () => { + const setup = await createManager(); + await startAndOffer(setup); + await dropChannel(setup); + expect(lastState(setup)).toBe("retrying in 1s"); + setup.manager.stop(); + await vi.advanceTimersByTimeAsync(60000); + expect(offerCount(setup)).toBe(1); + }); +}); + +describe("documentVisibility", () => { + it("wires the visibilitychange listener", () => { + const visibility = documentVisibility(); + let calls = 0; + const stop = visibility.onChange(() => { + calls += 1; + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(calls).toBe(1); + expect(visibility.isVisible()).toBe(document.visibilityState === "visible"); + stop(); + document.dispatchEvent(new Event("visibilitychange")); + expect(calls).toBe(1); + }); +}); diff --git a/apps/remote/web/test/relay.test.ts b/apps/remote/web/test/relay.test.ts new file mode 100644 index 0000000..53cfeba --- /dev/null +++ b/apps/remote/web/test/relay.test.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { parseHostsList } from "../src/relay.js"; +import type { RelayFrame } from "../src/relay.js"; +import { connectRelay, createRelayHarness } from "./fakes.js"; + +describe("RelaySocket", () => { + it("connects once and sends JSON envelopes", async () => { + const harness = createRelayHarness(); + const ws = await connectRelay(harness); + expect(harness.relay.connected).toBe(true); + harness.relay.send("h:abc", { t: "offer" }); + expect(ws.envelopes()).toEqual([{ to: "h:abc", body: { t: "offer" } }]); + }); + + it("joins a pending connect and reuses an open socket", async () => { + const harness = createRelayHarness(); + const first = harness.relay.connect(); + const second = harness.relay.connect(); + harness.latest().open(); + await first; + await second; + await harness.relay.connect(); + expect(harness.sockets).toHaveLength(1); + }); + + it("delivers incoming frames and ignores garbage", async () => { + const harness = createRelayHarness(); + await connectRelay(harness); + const frames: RelayFrame[] = []; + harness.relay.onFrame((frame) => frames.push(frame)); + const ws = harness.latest(); + ws.receive("h:abc", { t: "answer" }); + ws.receiveRaw("not json"); + ws.receiveRaw(JSON.stringify({ noFrom: true })); + ws.receiveRaw(JSON.stringify({ from: 42, body: {} })); + expect(frames).toEqual([{ from: "h:abc", body: { t: "answer" } }]); + }); + + it("rejects when the socket fails before opening", async () => { + const harness = createRelayHarness(); + const pending = harness.relay.connect(); + harness.latest().fail(); + await expect(pending).rejects.toThrow("Could not reach the relay"); + expect(harness.relay.connected).toBe(false); + }); + + it("notifies close subscribers", async () => { + const harness = createRelayHarness(); + await connectRelay(harness); + let closed = 0; + harness.relay.onClose(() => { + closed += 1; + }); + harness.latest().close(); + expect(closed).toBe(1); + expect(harness.relay.connected).toBe(false); + }); + + it("refuses to send while disconnected", () => { + const harness = createRelayHarness(); + expect(() => harness.relay.send("h:abc", {})).toThrow("Relay is not connected"); + expect(harness.relay.trySend("h:abc", {})).toBe(false); + }); +}); + +describe("parseHostsList", () => { + it("parses the relay hosts notice", () => { + expect(parseHostsList({ t: "hosts", items: [{ host_id: "a", label: "A" }] })).toEqual([ + { host_id: "a", label: "A" }, + ]); + }); + + it("drops malformed items and rejects other bodies", () => { + expect( + parseHostsList({ t: "hosts", items: [{ host_id: "a", label: "A" }, "junk", { host_id: "b" }] }), + ).toEqual([{ host_id: "a", label: "A" }]); + expect(parseHostsList({ t: "error", code: "no_target" })).toBeNull(); + expect(parseHostsList(null)).toBeNull(); + expect(parseHostsList({ t: "hosts" })).toBeNull(); + }); +}); diff --git a/apps/remote/web/test/setup.ts b/apps/remote/web/test/setup.ts new file mode 100644 index 0000000..4257c20 --- /dev/null +++ b/apps/remote/web/test/setup.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Test environment: WebCrypto from Node (happy-dom has no subtle crypto) +// and an in-memory IndexedDB. +import { webcrypto } from "node:crypto"; +import "fake-indexeddb/auto"; + +Object.defineProperty(globalThis, "crypto", { + value: webcrypto, + configurable: true, + writable: true, +}); diff --git a/apps/remote/web/test/store.test.ts b/apps/remote/web/test/store.test.ts new file mode 100644 index 0000000..be03e41 --- /dev/null +++ b/apps/remote/web/test/store.test.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +import { beforeEach, describe, expect, it } from "vitest"; +import { generateDeviceKey, signOffer } from "../src/crypto.js"; +import { + deleteHost, + getHost, + listHosts, + loadDeviceKey, + refreshDeviceId, + saveDeviceKey, + saveHost, +} from "../src/store.js"; + +async function clearDb(): Promise { + await new Promise((resolve, reject) => { + const req = globalThis.indexedDB.deleteDatabase("canopy-remote"); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error ?? new Error("delete failed")); + req.onblocked = () => resolve(); + }); +} + +beforeEach(clearDb); + +describe("device key storage", () => { + it("loads nothing when empty", async () => { + await expect(loadDeviceKey()).resolves.toBeNull(); + }); + + it("round-trips a non-extractable key that still signs", async () => { + const device = await generateDeviceKey(); + await saveDeviceKey(device); + const loaded = await loadDeviceKey(); + expect(loaded?.deviceId).toBe(device.deviceId); + expect(loaded?.spkiB64).toBe(device.spkiB64); + expect(loaded?.spki).toEqual(device.spki); + expect(loaded?.privateKey.extractable).toBe(false); + if (!loaded) { + throw new Error("expected a stored device key"); + } + const sig = await signOffer(loaded.privateKey, "host", "1", "sdp"); + expect(sig).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + }); + + it("refreshes a stale device id from the stored SPKI", async () => { + const device = await generateDeviceKey(); + await saveDeviceKey(device); + const stale = { ...device, deviceId: "0000000000000000" }; + const fixed = await refreshDeviceId(stale); + expect(fixed.deviceId).toBe(device.deviceId); + expect((await loadDeviceKey())?.deviceId).toBe(device.deviceId); + }); +}); + +describe("host storage", () => { + it("saves, gets, lists, and deletes hosts", async () => { + await expect(getHost("nope")).resolves.toBeNull(); + await saveHost({ host_id: "a", host_spki: "spki-a", label: "A" }); + await saveHost({ host_id: "b", host_spki: "spki-b", label: "B" }); + expect(await getHost("a")).toEqual({ host_id: "a", host_spki: "spki-a", label: "A" }); + expect(await listHosts()).toHaveLength(2); + await deleteHost("a"); + expect(await getHost("a")).toBeNull(); + expect(await listHosts()).toEqual([{ host_id: "b", host_spki: "spki-b", label: "B" }]); + }); + + it("overwrites a re-paired host", async () => { + await saveHost({ host_id: "a", host_spki: "old", label: "Old" }); + await saveHost({ host_id: "a", host_spki: "new", label: "New" }); + expect(await getHost("a")).toEqual({ host_id: "a", host_spki: "new", label: "New" }); + }); +}); diff --git a/apps/remote/web/test/ui.test.ts b/apps/remote/web/test/ui.test.ts new file mode 100644 index 0000000..b7f4a14 --- /dev/null +++ b/apps/remote/web/test/ui.test.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { connectFailMessage } from "../src/peer.js"; +import { consumePairFragment, createApp } from "../src/ui.js"; +import type { AppUi, PairSubmit } from "../src/ui.js"; + +describe("consumePairFragment", () => { + it("takes the code and removes the fragment", () => { + globalThis.location.hash = "#7K3QH-M9P2X"; + expect(consumePairFragment()).toBe("7K3QH-M9P2X"); + expect(globalThis.location.hash).toBe(""); + }); + + it("returns null without a fragment", () => { + globalThis.location.hash = ""; + expect(consumePairFragment()).toBeNull(); + }); +}); + +describe("createApp", () => { + let root: HTMLDivElement; + let app: AppUi; + let pairs: PairSubmit[]; + let connected: string[]; + let disconnects: number; + let retries: number; + let pairAgains: number; + + const query = (selector: string): T => { + const el = root.querySelector(selector); + if (!el) { + throw new Error(`missing ${selector}`); + } + return el as T; + }; + + const submitForm = () => { + query("#pair-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + pairs = []; + connected = []; + disconnects = 0; + retries = 0; + pairAgains = 0; + app = createApp(root, { + onPair: (submit) => pairs.push(submit), + onConnect: (hostId) => connected.push(hostId), + onDisconnect: () => { + disconnects += 1; + }, + onRetry: () => { + retries += 1; + }, + onPairAgain: () => { + pairAgains += 1; + }, + }); + }); + + afterEach(() => { + root.remove(); + document.body.innerHTML = ""; + }); + + it("prefills the code and the device label on the pair form", () => { + app.showPair("7K3QH-M9P2X"); + expect(query("#pair-code").value).toBe("7K3QH-M9P2X"); + expect(query("#pair-label").value).toContain(" on "); + }); + + it("preselects the host when exactly one is online", () => { + app.showPair(); + app.setOnlineHosts([{ host_id: "h1", label: "Host one" }]); + const select = query("#pair-host"); + expect(select.value).toBe("h1"); + expect(select.options).toHaveLength(1); + }); + + it("asks which host when several are online", () => { + app.showPair(); + app.setOnlineHosts([ + { host_id: "h1", label: "Host one" }, + { host_id: "h2", label: "Host two" }, + ]); + const select = query("#pair-host"); + expect(select.value).toBe(""); + expect(select.options).toHaveLength(3); + expect(select.options[0]?.textContent).toContain("Choose a host"); + }); + + it("keeps the typed code when the host list refreshes", () => { + app.showPair(); + query("#pair-code").value = "7K3QH-M9P"; + app.setOnlineHosts([{ host_id: "h1", label: "Host one" }]); + expect(query("#pair-code").value).toBe("7K3QH-M9P"); + }); + + it("submits the pair form", () => { + app.showPair(); + app.setOnlineHosts([{ host_id: "h1", label: "Host one" }]); + query("#pair-code").value = "7K3QH-M9P2X"; + submitForm(); + expect(pairs).toHaveLength(1); + expect(pairs[0]?.hostId).toBe("h1"); + expect(pairs[0]?.code).toBe("7K3QH-M9P2X"); + expect(pairs[0]?.label).toContain(" on "); + }); + + it("refuses to submit without a code", () => { + app.showPair(); + app.setOnlineHosts([{ host_id: "h1", label: "Host one" }]); + submitForm(); + expect(pairs).toHaveLength(0); + expect(query("#pair-error").textContent).toBe("Enter the pairing code"); + }); + + it("refuses to submit without a host", () => { + app.showPair(); + app.setOnlineHosts([]); + query("#pair-code").value = "7K3QH-M9P2X"; + submitForm(); + expect(pairs).toHaveLength(0); + expect(query("#pair-error").textContent).toBe("No hosts online"); + }); + + it("shows pair errors and the busy state", () => { + app.showPair(); + app.setPairError("Wrong code"); + expect(query("#pair-error").textContent).toBe("Wrong code"); + app.setPairBusy(true); + expect(query("#pair-form button[type=submit]").disabled).toBe(true); + expect(query("#pair-code").disabled).toBe(true); + app.setPairBusy(false); + expect(query("#pair-form button[type=submit]").disabled).toBe(false); + }); + + it("lists paired hosts with connect buttons", () => { + app.setPairedHosts([ + { host_id: "h1", host_spki: "s1", label: "Host one" }, + { host_id: "h2", host_spki: "s2", label: "Host two" }, + ]); + app.showHosts(); + const buttons = [...root.querySelectorAll("[data-connect]")]; + expect(buttons).toHaveLength(2); + buttons[1]?.click(); + expect(connected).toEqual(["h2"]); + query("#pair-new").click(); + expect(pairAgains).toBe(1); + }); + + it("shows the connection state with retry on failure", () => { + app.showSession("Host one"); + expect(query("#conn-state").textContent).toBe("connecting"); + app.setConnectionState("connected"); + expect(query("#conn-state").textContent).toBe("connected"); + expect(root.querySelector("#conn-retry")).toBeNull(); + app.setConnectionState("failed: Too many devices connected"); + expect(query("#conn-state").textContent).toBe("failed: Too many devices connected"); + query("#conn-retry").click(); + expect(retries).toBe(1); + expect(root.querySelector("#conn-pair-again")).toBeNull(); + query("#conn-disconnect").click(); + expect(disconnects).toBe(1); + }); + + it("offers to pair again when the browser is no longer paired", () => { + app.showSession("Host one"); + app.setConnectionState(`failed: ${connectFailMessage("unknown_device")}`); + query("#conn-pair-again").click(); + expect(pairAgains).toBe(1); + }); +}); diff --git a/apps/remote/web/test/vectors.ts b/apps/remote/web/test/vectors.ts new file mode 100644 index 0000000..595a7d2 --- /dev/null +++ b/apps/remote/web/test/vectors.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// The d4eb8558 contract vectors, shared by the web tests. + +export const CODE = "7K3QH-M9P2X"; +export const CODE_NORMALIZED = "7K3QHM9P2X"; +export const HOST_ID = "76805a63a6980c18"; +export const DEVICE_ID = "bfcba2b742a807bb"; +export const DEVICE_SPKI_B64 = + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1lqTl3yqPRsIGFL/V6eeRl8WYFdzBLrq1QXdOkhYnPNQGF6JU3LfYiHqOhN1V+Rz/dtnVfBb1QfDxTP86ckShQ=="; +export const HOST_SPKI_B64 = + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAhfmF/C2RDkoJ4+WmZ5pojpPLBUr321s32bluAKC1O0ZSn3ry5dxLS3aPKhaqHZaVvRfx1hZllLyiXxlMG5XlA=="; +export const MAC_INIT = "kpAbG/Gfg4QfoIVR/ey/Iop5TcNowq4mIrO29NyTL6o="; +export const MAC_OK = "1dCqDScWc5KVefuLcEqLHPTPVfNvO1QsjcW/kytm/RE="; +export const OFFER_SIG = + "IjIQ+FdCMvFYZfODE7OpflYnZlg0aWjc3GWfDBK9CLk5f8TWURvcsDpoNbvvh4Iaz/7MUmkEo6571C6asBI6UQ=="; +export const ANSWER_SDP = "v=0\r\no=- 3 4 IN IP4 127.0.0.1\r\n"; +export const ANSWER_SIG = + "cj0zq+BTNi23U+rvtXTSwl080GqqqkLVV057yGTxdQ4NCb5zrQhsKlbBvncvBx7hfXHjfv9Ete03GD6tJiLFMg=="; + +export const nonceB = (): Uint8Array => Uint8Array.from({ length: 16 }, (_, i) => i); +export const nonceH = (): Uint8Array => Uint8Array.from({ length: 16 }, (_, i) => i + 16); diff --git a/apps/remote/web/tsconfig.json b/apps/remote/web/tsconfig.json new file mode 100644 index 0000000..2df697b --- /dev/null +++ b/apps/remote/web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "vite.config.ts"] +} diff --git a/apps/remote/web/vite.config.ts b/apps/remote/web/vite.config.ts new file mode 100644 index 0000000..7632f39 --- /dev/null +++ b/apps/remote/web/vite.config.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/apps/remote/web/vitest.config.ts b/apps/remote/web/vitest.config.ts new file mode 100644 index 0000000..f2fcf6d --- /dev/null +++ b/apps/remote/web/vitest.config.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["apps/remote/web/test/**/*.test.ts"], + environment: "happy-dom", + setupFiles: ["apps/remote/web/test/setup.ts"], + }, +}); diff --git a/eslint.config.js b/eslint.config.js index 8e9d869..174961f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["node_modules/", "dist/", ".wrangler/", "coverage/"] }, + { ignores: ["node_modules/", "dist/", "apps/remote/web/dist/", ".wrangler/", "coverage/"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index f1edd5e..a198143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,12 @@ "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", + "fake-indexeddb": "6.2.5", + "happy-dom": "21.0.1", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "8.71.2", + "vite": "8.3.4", "vitest": "^3.0.5", "wrangler": "^4.153.0" }, @@ -2051,6 +2054,16 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -2093,6 +2106,286 @@ "dev": true, "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.65.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", @@ -2544,6 +2837,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", @@ -2843,33 +3153,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", @@ -3039,6 +3322,19 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3211,6 +3507,19 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -3458,6 +3767,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3589,6 +3908,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/happy-dom": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.1.tgz", + "integrity": "sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3751,6 +4111,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4044,6 +4677,40 @@ "node": ">=4" } }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, "node_modules/rollup": { "version": "4.65.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", @@ -4431,18 +5098,17 @@ } }, "node_modules/vite": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", - "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4458,9 +5124,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4473,13 +5140,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -4528,6 +5198,81 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", @@ -4601,6 +5346,118 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 1f3f8d7..8c93ea8 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,12 @@ "node": ">=22" }, "scripts": { + "build": "npm run build:remote-web", + "build:remote-web": "vite build --config apps/remote/web/vite.config.ts", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "vitest run && npm run test:worker", + "test": "vitest run && npm run test:worker && npm run test:remote-web", + "test:remote-web": "vitest run --config apps/remote/web/vitest.config.ts", "test:worker": "vitest run --config apps/graphs/vitest.config.ts", "validate": "tsx src/validate.ts" }, @@ -23,9 +26,12 @@ "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", + "fake-indexeddb": "6.2.5", + "happy-dom": "21.0.1", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "8.71.2", + "vite": "8.3.4", "vitest": "^3.0.5", "wrangler": "^4.153.0" } diff --git a/tsconfig.json b/tsconfig.json index 4abbeae..c302ec2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": false, "outDir": "dist", "types": ["node", "@cloudflare/vitest-pool-workers"], - "lib": ["ES2022"] + "lib": ["ES2022", "DOM"] }, "include": [ "src/**/*.ts", @@ -19,6 +19,7 @@ "vitest.config.ts", "apps/*/src/**/*.ts", "apps/*/test/**/*.ts", - "apps/*/vitest.config.ts" + "apps/*/vitest.config.ts", + "apps/*/vite.config.ts" ] } From c1ece0faddf0f832e6ba1cc42759a9df35774387 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 20:53:00 -0500 Subject: [PATCH 015/180] feat(relay): run a blind signaling relay with GitHub sign-in on remote.univerlab.org (reviewer 2 fixes) --- apps/remote/worker/src/index.ts | 32 +++++++++++++++-------- apps/remote/worker/src/room.ts | 11 +++++--- apps/remote/worker/src/turn.ts | 2 +- apps/remote/worker/test/headers.test.ts | 22 ++++++++++++++++ apps/remote/worker/test/room.test.ts | 34 +++++++++++++++++++++++++ apps/remote/worker/test/turn.test.ts | 13 +++++----- 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/apps/remote/worker/src/index.ts b/apps/remote/worker/src/index.ts index df5140f..2796901 100644 --- a/apps/remote/worker/src/index.ts +++ b/apps/remote/worker/src/index.ts @@ -35,32 +35,44 @@ const ROUTE_METHODS: Record = { }; export default { - fetch(req: Request, env: Env): Response | Promise { + fetch(req: Request, env: Env): Promise { const pathname = new URL(req.url).pathname; const method = ROUTE_METHODS[pathname]; if (method === undefined) { - return json({ error: "not_found" }, 404); + return Promise.resolve(json({ error: "not_found" }, 404)); } if (req.method !== method) { - return json({ error: "method_not_allowed" }, 405); + return Promise.resolve(json({ error: "method_not_allowed" }, 405)); } + return dispatch(pathname, req, env); + }, +}; + +/** + * Run the routed handler; an unexpected throw or rejection answers 500 + * with the security headers, never the runtime's bare error page. + */ +async function dispatch(pathname: string, req: Request, env: Env): Promise { + try { switch (pathname) { case "/auth/login": return handleLogin(req, env); case "/auth/callback": - return handleCallback(req, env); + return await handleCallback(req, env); case "/auth/logout": return handleLogout(req, env); case "/auth/me": - return handleMe(req, env); + return await handleMe(req, env); case "/ws/device": - return handleDeviceWs(req, env); + return await handleDeviceWs(req, env); case "/ws/host": - return handleHostWs(req, env); + return await handleHostWs(req, env); case "/turn": - return handleTurn(req, env); + return await handleTurn(req, env); default: return json({ error: "not_found" }, 404); } - }, -}; + } catch { + return json({ error: "internal" }, 500); + } +} diff --git a/apps/remote/worker/src/room.ts b/apps/remote/worker/src/room.ts index f27069f..6d0e8aa 100644 --- a/apps/remote/worker/src/room.ts +++ b/apps/remote/worker/src/room.ts @@ -85,10 +85,13 @@ export class UserRoom { } if (role === "device" && this.deviceCount() >= MAX_DEVICES) { const pair = new WebSocketPair(); - // The runtime only delivers the close on an accepted socket; the - // socket still never joins the room. - pair[1].accept(); - pair[1].close(4008, "too_many_devices"); + const refused = pair[1]; + // Through the hibernation API like every other socket: the runtime + // only delivers the close on an accepted socket. It carries no + // attachment, so it is invisible to counts and broadcasts, and its + // close removes it again. + this.state.acceptWebSocket(refused); + refused.close(4008, "too_many_devices"); return upgradeResponse(pair[0]); } if (role === "host") { diff --git a/apps/remote/worker/src/turn.ts b/apps/remote/worker/src/turn.ts index 8e6434f..24e54ac 100644 --- a/apps/remote/worker/src/turn.ts +++ b/apps/remote/worker/src/turn.ts @@ -70,7 +70,7 @@ export async function handleTurn( let response: Response; try { response = await fetchImpl( - `https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(keyId)}/credentials/generate`, + `https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(keyId)}/credentials/generate-ice-servers`, { method: "POST", headers: { diff --git a/apps/remote/worker/test/headers.test.ts b/apps/remote/worker/test/headers.test.ts index 285d0ce..0c1ce8b 100644 --- a/apps/remote/worker/test/headers.test.ts +++ b/apps/remote/worker/test/headers.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; import worker, { type Env } from "../src/index.js"; +import { signSession } from "../src/auth.js"; import { UserRoom } from "../src/room.js"; import type { RoomNamespace, RoomStub } from "../src/ws.js"; import { FakeRoomState } from "./fakes.js"; @@ -69,6 +70,27 @@ describe("security headers", () => { }); } + it("carries the headers on an internal 500", async () => { + const throwing: Env = { + ...env, + USER_ROOM: { + idFromName: (name: string): unknown => { + void name; + throw new Error("boom"); + }, + get: (id: unknown): RoomStub => { + void id; + throw new Error("boom"); + }, + }, + }; + const session = await signSession(123, "octo", env.SESSION_SECRET as string, Math.floor(Date.now() / 1000)); + const response = await worker.fetch(request("/ws/device", { cookie: `cr_session=${session}` }), throwing); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "internal" }); + expectSecurityHeaders(response); + }); + it("carries the headers on the room's 426 and 400", () => { const room = new UserRoom(new FakeRoomState()); const upgrade = new Request("https://user-room/", { diff --git a/apps/remote/worker/test/room.test.ts b/apps/remote/worker/test/room.test.ts index 686d313..6c5a16d 100644 --- a/apps/remote/worker/test/room.test.ts +++ b/apps/remote/worker/test/room.test.ts @@ -190,6 +190,14 @@ describe("room fetch", () => { const ninth = clientOf(room.fetch(upgradeRequest("device"))); ninth.accept(); expect(await nextClose(ninth)).toBe(4008); + // Mirror the runtime: it drops the closed, unattached socket before + // calling webSocketClose, which then finds nothing to broadcast. + for (const socket of state.getWebSockets()) { + if (socket.deserializeAttachment() === null) { + state.remove(socket); + room.webSocketClose(socket); + } + } expect(state.getWebSockets()).toHaveLength(8); }); }); @@ -234,6 +242,32 @@ describe("routing", () => { expect(device.frames()).toEqual([{ from: "relay", body: { t: "error", code: "too_large" } }]); }); + it("pins the exact envelope bytes a host matches", () => { + const { state, room } = setup(); + const device = attachFake(state, room, "device"); + const host = attachFake(state, room, "host", { host_id: HOST_ID, label: "devbox" }); + room.webSocketMessage(device, JSON.stringify({ to: `h:${HOST_ID}`, body: { t: "offer", sdp: "v=0" } })); + expect(host.sent).toEqual([`{"from":"d:1","body":{"t":"offer","sdp":"v=0"}}`]); + room.webSocketMessage(host, JSON.stringify({ to: "d:1", body: { t: "answer" } })); + room.webSocketMessage(device, JSON.stringify({ to: "h:deadbeefdeadbeef", body: { t: "offer" } })); + expect(device.sent).toEqual([ + `{"from":"h:${HOST_ID}","body":{"t":"answer"}}`, + `{"from":"relay","body":{"t":"error","code":"no_target"}}`, + ]); + }); + + it("pins the exact hosts notice bytes", async () => { + const { room } = setup(); + const device = clientOf(room.fetch(upgradeRequest("device"))); + device.accept(); + expect(await nextMessage(device)).toBe(`{"from":"relay","body":{"t":"hosts","items":[]}}`); + const seen = nextMessage(device); + room.fetch(upgradeRequest("host", { "x-relay-host-id": HOST_ID, "x-relay-label": "devbox" })); + expect(await seen).toBe( + `{"from":"relay","body":{"t":"hosts","items":[{"host_id":"${HOST_ID}","label":"devbox"}]}}`, + ); + }); + it("rejects malformed and binary frames", () => { const { state, room } = setup(); const device = attachFake(state, room, "device"); diff --git a/apps/remote/worker/test/turn.test.ts b/apps/remote/worker/test/turn.test.ts index 10562a4..f1a5cdb 100644 --- a/apps/remote/worker/test/turn.test.ts +++ b/apps/remote/worker/test/turn.test.ts @@ -48,7 +48,7 @@ beforeEach(() => { describe("GET /turn", () => { it("serves a cookied browser with upstream entries plus STUN", async () => { - const fake = fakeFetch([{ url: "credentials/generate", status: 200, body: UPSTREAM }]); + const fake = fakeFetch([{ url: "credentials/generate-ice-servers", status: 201, body: UPSTREAM }]); const { fetchImpl, seen } = recording(fake); const session = await signSession(123, "octo", SECRET, Math.floor(Date.now() / 1000)); const response = await handleTurn(request(`cr_session=${session}`), env, fetchImpl); @@ -56,15 +56,16 @@ describe("GET /turn", () => { expect(await response.json()).toEqual({ iceServers: [...UPSTREAM.iceServers, { urls: "stun:stun.cloudflare.com:3478" }], }); - expect(fake.calls).toHaveLength(1); - expect(fake.calls[0]).toContain("rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate"); + expect(fake.calls).toEqual([ + "https://rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate-ice-servers", + ]); expect(JSON.parse(fake.bodies[0] as string)).toEqual({ ttl: 3600 }); expect(seen[0]?.["authorization"]).toBe("Bearer turn-api-token"); }); it("serves a host bearer without touching GitHub twice", async () => { const fake = fakeFetch([ - { url: "credentials/generate", status: 200, body: UPSTREAM }, + { url: "credentials/generate-ice-servers", status: 200, body: UPSTREAM }, { url: "api.github.com/user", status: 200, body: { id: 456, login: "host-user" } }, ]); const first = await handleTurn(request(undefined, "gho_valid"), env, fake.fetchImpl); @@ -76,7 +77,7 @@ describe("GET /turn", () => { it("answers 502 when the upstream fails, without forwarding its body", async () => { const fake = fakeFetch([ - { url: "credentials/generate", status: 500, body: { internal: "details" } }, + { url: "credentials/generate-ice-servers", status: 500, body: { internal: "details" } }, ]); const session = await signSession(123, "octo", SECRET, Math.floor(Date.now() / 1000)); const response = await handleTurn(request(`cr_session=${session}`), env, fake.fetchImpl); @@ -85,7 +86,7 @@ describe("GET /turn", () => { }); it("answers 401 with neither credential", async () => { - const fake = fakeFetch([{ url: "credentials/generate", status: 200, body: UPSTREAM }]); + const fake = fakeFetch([{ url: "credentials/generate-ice-servers", status: 200, body: UPSTREAM }]); const response = await handleTurn(request(), env, fake.fetchImpl); expect(response.status).toBe(401); expect(await response.json()).toEqual({ error: "unauthorized" }); From 8a896ca508bc19e61b917d89b0ab7aa777bc0212 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 20:59:31 -0500 Subject: [PATCH 016/180] feat(pairing): pair the browser with canopy and reconnect with its device key (reviewer 2 fixes) --- apps/remote/web/src/pairing.ts | 7 ++++++- apps/remote/web/src/peer.ts | 5 ++++- apps/remote/web/src/reconnect.ts | 4 ++-- apps/remote/web/src/ui.ts | 8 ++++++-- apps/remote/web/test/pairing.test.ts | 24 ++++++++++++++++++++++++ apps/remote/web/test/peer.test.ts | 17 +++++++++++++++++ apps/remote/web/test/reconnect.test.ts | 10 ++++++++++ apps/remote/web/test/ui.test.ts | 6 ++++++ apps/remote/web/tsconfig.json | 2 +- tsconfig.json | 8 ++++++-- 10 files changed, 82 insertions(+), 9 deletions(-) diff --git a/apps/remote/web/src/pairing.ts b/apps/remote/web/src/pairing.ts index 3707e2d..399044d 100644 --- a/apps/remote/web/src/pairing.ts +++ b/apps/remote/web/src/pairing.ts @@ -148,7 +148,12 @@ export async function runPairing(deps: PairingDeps): Promise { if (!macEqual(expected, reply.mac)) { throw new Error("Host identity check failed"); } - const spkiId = await idFromSpki(b64decode(reply.host_spki)); + let spkiId: string; + try { + spkiId = await idFromSpki(b64decode(reply.host_spki)); + } catch { + throw new Error("Host identity check failed"); + } if (!spkiId.startsWith(deps.hostId)) { throw new Error("Host identity check failed"); } diff --git a/apps/remote/web/src/peer.ts b/apps/remote/web/src/peer.ts index 720b5e0..9eafa64 100644 --- a/apps/remote/web/src/peer.ts +++ b/apps/remote/web/src/peer.ts @@ -279,6 +279,9 @@ export class HostPeer { if (!this.pc || !this.hostKey || this.offerSig === "") { return; } + if (this.pc.remoteDescription) { + return; + } const valid = await verifyAnswer( this.hostKey, this.device.deviceId, @@ -343,7 +346,7 @@ export class HostPeer { } this.pc = null; } - if (sendBye && first) { + if (sendBye && first && this.offerSig !== "") { this.relay.trySend(this.target, { t: "bye" }); } } diff --git a/apps/remote/web/src/reconnect.ts b/apps/remote/web/src/reconnect.ts index 894a1a5..4c2968e 100644 --- a/apps/remote/web/src/reconnect.ts +++ b/apps/remote/web/src/reconnect.ts @@ -176,7 +176,7 @@ export class ConnectionManager { } private onIceState(state: string): void { - if (this.phase !== "open") { + if (this.phase !== "open" && this.phase !== "attempting") { return; } if (state === "failed") { @@ -189,7 +189,7 @@ export class ConnectionManager { } this.graceTimer = setTimeout(() => { this.graceTimer = null; - if (this.phase === "open") { + if (this.phase === "open" || this.phase === "attempting") { this.beginWait(); } }, DISCONNECT_GRACE_MS); diff --git a/apps/remote/web/src/ui.ts b/apps/remote/web/src/ui.ts index 423cdaa..3247910 100644 --- a/apps/remote/web/src/ui.ts +++ b/apps/remote/web/src/ui.ts @@ -38,13 +38,17 @@ export function consumePairFragment(): string | null { if (!hash || hash.length < 2) { return null; } - const code = decodeURIComponent(hash.slice(1)); + const raw = hash.slice(1); globalThis.history.replaceState( null, "", globalThis.location.pathname + globalThis.location.search, ); - return code; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } } function esc(text: string): string { diff --git a/apps/remote/web/test/pairing.test.ts b/apps/remote/web/test/pairing.test.ts index 5ad6c08..3a69373 100644 --- a/apps/remote/web/test/pairing.test.ts +++ b/apps/remote/web/test/pairing.test.ts @@ -140,6 +140,30 @@ describe("runPairing", () => { await assertion; }); + it("maps an undecodable host_spki to the identity check failure", async () => { + const setup = await setupPairing(); + const key = await derivePairKey(CODE_NORMALIZED, HOST_ID); + const badSpki = "!!!not-base64!!!"; + const mac = await pairOkMac( + key, + badSpki, + DEVICE_SPKI_B64, + b64encode(nonceB()), + b64encode(nonceH()), + ); + const pending = runPairing(pairOptions(setup)); + const assertion = expect(pending).rejects.toThrow("Host identity check failed"); + await waitForSend(setup.ws); + setup.ws.receive(`h:${HOST_ID}`, { + t: "pair_ok", + host_spki: badSpki, + nonce_h: b64encode(nonceH()), + mac, + }); + await assertion; + expect(setup.saved).toHaveLength(0); + }); + it("rejects a malformed code without sending", async () => { const setup = await setupPairing(); await expect(runPairing(pairOptions(setup, { code: "bogus" }))).rejects.toThrow( diff --git a/apps/remote/web/test/peer.test.ts b/apps/remote/web/test/peer.test.ts index 91fc362..da35155 100644 --- a/apps/remote/web/test/peer.test.ts +++ b/apps/remote/web/test/peer.test.ts @@ -209,4 +209,21 @@ describe("HostPeer", () => { const setup = await setupPeer(false); await expect(setup.peer.connect()).rejects.toThrow("Could not load TURN credentials"); }); + + it("sends no bye when the offer was never sent", async () => { + const setup = await setupPeer(false); + await expect(setup.peer.connect()).rejects.toThrow("Could not load TURN credentials"); + expect(setup.ws.envelopes().filter((e) => e.body.t === "bye")).toHaveLength(0); + }); + + it("ignores a second answer after the handshake completed", async () => { + const setup = await setupAndOpen(); + const offer = offerBody(setup); + const forged = `${offer.sig.slice(0, -4)}MA==`; + setup.ws.receive(`h:${setup.host.record.host_id}`, { t: "answer", sdp: ANSWER_SDP, sig: forged }); + await flush(); + await flush(); + expect(setup.events).not.toContainEqual({ type: "failed", reason: "Host identity check failed" }); + expect(setup.peerHarness.latest().closed).toBe(false); + }); }); diff --git a/apps/remote/web/test/reconnect.test.ts b/apps/remote/web/test/reconnect.test.ts index 69eedbc..8030791 100644 --- a/apps/remote/web/test/reconnect.test.ts +++ b/apps/remote/web/test/reconnect.test.ts @@ -218,6 +218,16 @@ describe("ConnectionManager", () => { expect(offerCount(setup)).toBe(1); }); + it("retries on ICE failed during the handshake", async () => { + const setup = await createManager(); + await startAndOffer(setup); + setup.peerHarness.latest().setConnectionState("failed"); + await settle(10); + expect(lastState(setup)).toBe("retrying in 1s"); + await vi.advanceTimersByTimeAsync(1000); + await waitForOffers(setup, 2); + }); + it("retries at once on ICE failed", async () => { const setup = await createManager(); await startAndOffer(setup); diff --git a/apps/remote/web/test/ui.test.ts b/apps/remote/web/test/ui.test.ts index b7f4a14..97036b1 100644 --- a/apps/remote/web/test/ui.test.ts +++ b/apps/remote/web/test/ui.test.ts @@ -15,6 +15,12 @@ describe("consumePairFragment", () => { globalThis.location.hash = ""; expect(consumePairFragment()).toBeNull(); }); + + it("still strips a malformed fragment", () => { + globalThis.location.hash = "#%E0%A4%A"; + expect(consumePairFragment()).toBe("%E0%A4%A"); + expect(globalThis.location.hash).toBe(""); + }); }); describe("createApp", () => { diff --git a/apps/remote/web/tsconfig.json b/apps/remote/web/tsconfig.json index 2df697b..63b2abb 100644 --- a/apps/remote/web/tsconfig.json +++ b/apps/remote/web/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true }, "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "vite.config.ts"] diff --git a/tsconfig.json b/tsconfig.json index 41c2db3..eaeae33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": false, "outDir": "dist", "types": ["node", "@cloudflare/vitest-pool-workers"], - "lib": ["ES2022", "DOM"] + "lib": ["ES2022", "DOM", "DOM.Iterable"] }, "include": [ "src/**/*.ts", @@ -23,6 +23,10 @@ "apps/*/worker/src/**/*.ts", "apps/*/worker/test/**/*.ts", "apps/*/worker/vitest.config.ts", - "apps/*/vite.config.ts" + "apps/*/vite.config.ts", + "apps/*/web/src/**/*.ts", + "apps/*/web/test/**/*.ts", + "apps/*/web/vitest.config.ts", + "apps/*/web/vite.config.ts" ] } From 27e47af85657e617f23b286e8bdd89d858c098ed Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 21:19:02 -0500 Subject: [PATCH 017/180] feat(sessions): show the host's sessions with a live terminal, a key bar and a prompt composer --- .gitignore | 4 + apps/remote/web/fake.html | 12 + apps/remote/web/index.html | 20 +- apps/remote/web/playwright.config.ts | 22 + apps/remote/web/src/app.ts | 496 +++++++++++++++++++++ apps/remote/web/src/composer.ts | 129 ++++++ apps/remote/web/src/fake-host.ts | 152 +++++++ apps/remote/web/src/keys.ts | 49 ++ apps/remote/web/src/main.ts | 252 +++++++++-- apps/remote/web/src/peer.ts | 14 + apps/remote/web/src/protocol.ts | 234 ++++++++++ apps/remote/web/src/reconnect.ts | 12 + apps/remote/web/src/session.ts | 153 +++++++ apps/remote/web/src/sessions.ts | 117 +++++ apps/remote/web/src/styles.css | 406 +++++++++++++++++ apps/remote/web/src/terminal.ts | 273 ++++++++++++ apps/remote/web/src/vite-env.d.ts | 2 + apps/remote/web/test/app.test.ts | 339 ++++++++++++++ apps/remote/web/test/composer.test.ts | 97 ++++ apps/remote/web/test/keys.test.ts | 62 +++ apps/remote/web/test/protocol.test.ts | 171 +++++++ apps/remote/web/test/send.test.ts | 144 ++++++ apps/remote/web/test/session.test.ts | 199 +++++++++ apps/remote/web/test/sessions.test.ts | 96 ++++ apps/remote/web/test/smoke.spec.ts | 52 +++ apps/remote/web/test/terminal-view.test.ts | 169 +++++++ package-lock.json | 55 ++- package.json | 7 +- 28 files changed, 3667 insertions(+), 71 deletions(-) create mode 100644 apps/remote/web/fake.html create mode 100644 apps/remote/web/playwright.config.ts create mode 100644 apps/remote/web/src/app.ts create mode 100644 apps/remote/web/src/composer.ts create mode 100644 apps/remote/web/src/fake-host.ts create mode 100644 apps/remote/web/src/keys.ts create mode 100644 apps/remote/web/src/protocol.ts create mode 100644 apps/remote/web/src/session.ts create mode 100644 apps/remote/web/src/sessions.ts create mode 100644 apps/remote/web/src/styles.css create mode 100644 apps/remote/web/src/terminal.ts create mode 100644 apps/remote/web/src/vite-env.d.ts create mode 100644 apps/remote/web/test/app.test.ts create mode 100644 apps/remote/web/test/composer.test.ts create mode 100644 apps/remote/web/test/keys.test.ts create mode 100644 apps/remote/web/test/protocol.test.ts create mode 100644 apps/remote/web/test/send.test.ts create mode 100644 apps/remote/web/test/session.test.ts create mode 100644 apps/remote/web/test/sessions.test.ts create mode 100644 apps/remote/web/test/smoke.spec.ts create mode 100644 apps/remote/web/test/terminal-view.test.ts diff --git a/.gitignore b/.gitignore index f9c9f6f..ab6788d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,9 +99,13 @@ dist *.cer *.csr *.css +!apps/remote/web/src/styles.css *.htm *.html !apps/remote/web/index.html +!apps/remote/web/fake.html +test-results/ +playwright-report/ *.js # The ESLint flat config is source, not a build output. !eslint.config.js diff --git a/apps/remote/web/fake.html b/apps/remote/web/fake.html new file mode 100644 index 0000000..35c265b --- /dev/null +++ b/apps/remote/web/fake.html @@ -0,0 +1,12 @@ + + + + + + Canopy remote smoke + + +
    Loading…
    + + + diff --git a/apps/remote/web/index.html b/apps/remote/web/index.html index d72c834..f6cfb50 100644 --- a/apps/remote/web/index.html +++ b/apps/remote/web/index.html @@ -2,26 +2,8 @@ - + Canopy remote -
    Loading…
    diff --git a/apps/remote/web/playwright.config.ts b/apps/remote/web/playwright.config.ts new file mode 100644 index 0000000..2e59368 --- /dev/null +++ b/apps/remote/web/playwright.config.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./test", + testMatch: "smoke.spec.ts", + timeout: 60_000, + use: { + baseURL: "http://127.0.0.1:5199", + viewport: { width: 360, height: 640 }, + channel: "chrome", + launchOptions: { + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }, + }, + webServer: { + command: "npx vite --config ./vite.config.ts --port 5199 --strictPort", + url: "http://127.0.0.1:5199/fake.html", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts new file mode 100644 index 0000000..9069ea1 --- /dev/null +++ b/apps/remote/web/src/app.ts @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: Apache-2.0 +// The app shell: Sign in → Pair → Sessions → Session, with a header +// carrying the GitHub login, the host label and the connection state. +// The shell owns the views and the terminal/key-bar/composer widgets; +// main.ts owns the relay, the peer and the frame routing. A host picker +// appears only when several hosts are paired. + +import { Composer } from "./composer.js"; +import { renderKeyBar } from "./keys.js"; +import type { KeyDef } from "./keys.js"; +import { defaultDeviceLabel } from "./pairing.js"; +import { connectFailMessage } from "./peer.js"; +import type { OnlineHost } from "./relay.js"; +import { renderSessions } from "./sessions.js"; +import type { SessionStore } from "./sessions.js"; +import { createXtermHandle, TerminalView } from "./terminal.js"; +import type { TerminalFactory } from "./terminal.js"; +import type { PairSubmit } from "./ui.js"; + +export interface ShellCallbacks { + onPair: (submit: PairSubmit) => void; + onConnect: (hostId: string) => void; + onDisconnect: () => void; + onRetry: () => void; + onPairAgain: () => void; + onOpenSession: (id: string) => void; + onLeaveSession: () => void; + onKeyPress: (def: KeyDef) => void; + onTerminalInput: (text: string) => void; + onComposerSend: (text: string, req: string) => void; + onNew: () => void; + onBuilder: () => void; +} + +export interface Shell { + showSignIn: () => void; + showPair: (prefillCode?: string) => void; + showHosts: () => void; + showSessions: () => void; + showSession: (id: string, name: string) => void; + setLogin: (login: string | null) => void; + setHostLabel: (label: string) => void; + setConnectionState: (state: string) => void; + setOnlineHosts: (hosts: OnlineHost[]) => void; + setPairedHosts: (hosts: { host_id: string; label: string }[]) => void; + setPairError: (message: string | null) => void; + setPairBusy: (busy: boolean) => void; + ensureTerminal: (id: string, cols: number, rows: number) => void; + writeTerminal: (text: string) => void; + clearTerminal: () => void; + showSessionExited: (code: number) => void; + composerOk: (req: string) => void; + composerError: (message: string, req: string | null) => void; + dispose: () => void; +} + +export interface ShellDeps { + terminalFactory?: TerminalFactory; +} + +type Route = "signin" | "pair" | "hosts" | "sessions" | "session"; + +function esc(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function createShell( + root: HTMLElement, + store: SessionStore, + callbacks: ShellCallbacks, + deps?: ShellDeps, +): Shell { + const terminalFactory = deps?.terminalFactory ?? createXtermHandle; + let route: Route = "signin"; + let login: string | null = null; + let hostLabel = ""; + let connectionState = "connecting"; + let online: OnlineHost[] = []; + let paired: { host_id: string; label: string }[] = []; + let draft = { code: "", hostId: "", label: "" }; + let pairError: string | null = null; + let pairBusy = false; + let sessionId: string | null = null; + let sessionName = ""; + let sessionCols = 0; + let sessionRows = 0; + let terminalView: TerminalView | null = null; + let composer: Composer | null = null; + let latestButton: HTMLButtonElement | null = null; + + const snapshotDraft = () => { + const code = root.querySelector("#pair-code"); + const host = root.querySelector("#pair-host"); + const label = root.querySelector("#pair-label"); + if (code) { + draft = { code: code.value, hostId: host?.value ?? "", label: label?.value ?? "" }; + } + }; + + const failed = () => connectionState.startsWith("failed:"); + const unpaired = () => connectionState === `failed: ${connectFailMessage("unknown_device")}`; + + const header = (): string => ` +
    + Canopy + ${login === null ? "" : ``} + ${hostLabel === "" ? "" : `${esc(hostLabel)}`} + ${esc(connectionState)} +
    `; + + const connBanner = (): string => { + if (!failed() && !connectionState.startsWith("retrying")) { + return ""; + } + return ( + `

    ${esc(connectionState)} ` + + (failed() ? ` ` : "") + + (unpaired() ? `` : "") + + `

    ` + ); + }; + + const wireConnBanner = () => { + root.querySelector("#conn-retry")?.addEventListener("click", () => callbacks.onRetry()); + root.querySelector("#conn-pair-again")?.addEventListener("click", () => callbacks.onPairAgain()); + }; + + const hostOptions = (): string => { + if (online.length === 0) { + return ``; + } + if (online.length === 1 && online[0]) { + return ``; + } + const options = online.map( + (host) => + ``, + ); + const placeholder = draft.hostId === "" ? " selected" : ""; + return [``, ...options].join(""); + }; + + const renderSignIn = () => { + root.innerHTML = ` +
    +

    Canopy remote

    +

    Sign in with GitHub to reach your hosts.

    + +
    `; + root.querySelector("#signin-go")?.addEventListener("click", () => { + globalThis.location.href = "/auth/login"; + }); + }; + + const renderPair = () => { + root.innerHTML = ` + ${header()} +
    +

    Pair this browser

    +

    Enter the pairing code shown by canopy.

    +
    + + + + + +
    +
    `; + root.querySelector("#pair-form")?.addEventListener("submit", (ev) => { + ev.preventDefault(); + snapshotDraft(); + if (online.length === 0 || draft.hostId === "") { + pairError = online.length === 0 ? "No hosts online" : "Choose a host"; + render(); + return; + } + if (draft.code.trim() === "") { + pairError = "Enter the pairing code"; + render(); + return; + } + pairError = null; + callbacks.onPair({ hostId: draft.hostId, code: draft.code, label: draft.label }); + }); + }; + + const renderHosts = () => { + const rows = paired + .map( + (host) => `
  • ${esc(host.label)} +
  • `, + ) + .join(""); + root.innerHTML = ` + ${header()} +
    +

    Choose a host

    +
      ${rows}
    +

    +
    `; + for (const button of root.querySelectorAll("[data-connect]")) { + button.addEventListener("click", () => { + const hostId = button.getAttribute("data-connect"); + if (hostId) { + callbacks.onConnect(hostId); + } + }); + } + root.querySelector("#pair-new")?.addEventListener("click", () => callbacks.onPairAgain()); + }; + + const renderSessionsRoute = () => { + root.innerHTML = ` + ${header()} +
    + ${connBanner()} +
    +

    +
    `; + wireConnBanner(); + root.querySelector("#conn-disconnect")?.addEventListener("click", () => callbacks.onDisconnect()); + const slot = root.querySelector("#session-list"); + if (slot) { + renderSessionsList(slot); + } + }; + + const renderSessionsList = (slot: HTMLElement) => { + renderSessions(slot, store.list(), Date.now(), { + onOpen: (id) => callbacks.onOpenSession(id), + onNew: () => callbacks.onNew(), + }); + }; + + const teardownSessionWidgets = () => { + terminalView?.dispose(); + terminalView = null; + composer?.unmount(); + composer = null; + latestButton = null; + sessionCols = 0; + sessionRows = 0; + }; + + const renderSession = () => { + teardownSessionWidgets(); + root.innerHTML = ` + ${header()} +
    + ${connBanner()} +
    + + ${esc(sessionName)} + +
    +
    +

    Attaching…

    +
    +
    + + + +
    +
    +
    +
    `; + wireConnBanner(); + root.querySelector("#session-back")?.addEventListener("click", () => callbacks.onLeaveSession()); + root.querySelector("#term-zoom-in")?.addEventListener("click", () => terminalView?.zoomIn()); + root.querySelector("#term-zoom-out")?.addEventListener("click", () => terminalView?.zoomOut()); + latestButton = root.querySelector("#term-latest"); + latestButton?.addEventListener("click", () => { + terminalView?.scrollToLatest(); + if (latestButton) { + latestButton.hidden = true; + } + }); + const bar = root.querySelector("#keybar"); + if (bar) { + renderKeyBar(bar, (def) => callbacks.onKeyPress(def)); + } + const slot = root.querySelector("#composer-slot"); + if (slot) { + composer = new Composer({ + onSend: (text, req) => callbacks.onComposerSend(text, req), + onBuilder: () => callbacks.onBuilder(), + }); + composer.mount(slot); + } + }; + + const render = () => { + if (route === "signin") { + renderSignIn(); + } else if (route === "pair") { + renderPair(); + } else if (route === "hosts") { + renderHosts(); + } else if (route === "sessions") { + renderSessionsRoute(); + } else { + renderSession(); + } + }; + + const stopStore = store.subscribe(() => { + if (route === "sessions") { + const slot = root.querySelector("#session-list"); + if (slot) { + renderSessionsList(slot); + } + } + }); + + // The "Ns ago" labels go stale; refresh them while Sessions is visible. + const ticker = globalThis.setInterval(() => { + if (route === "sessions") { + const slot = root.querySelector("#session-list"); + if (slot) { + renderSessionsList(slot); + } + } + }, 15000); + + return { + showSignIn(): void { + route = "signin"; + render(); + }, + showPair(prefillCode?: string): void { + route = "pair"; + pairError = null; + pairBusy = false; + draft = { + code: prefillCode ?? draft.code, + hostId: online.length === 1 && online[0] ? online[0].host_id : draft.hostId, + label: draft.label === "" ? defaultDeviceLabel() : draft.label, + }; + render(); + }, + showHosts(): void { + route = "hosts"; + render(); + }, + showSessions(): void { + teardownSessionWidgets(); + sessionId = null; + route = "sessions"; + render(); + }, + showSession(id: string, name: string): void { + sessionId = id; + sessionName = name; + route = "session"; + render(); + }, + setLogin(next: string | null): void { + login = next; + if (route !== "signin") { + render(); + } + }, + setHostLabel(label: string): void { + hostLabel = label; + if (route !== "signin") { + render(); + } + }, + setConnectionState(state: string): void { + connectionState = state; + if (route === "sessions" || route === "session") { + // Never a full re-render here: it would drop the terminal and + // the composer's draft. Only the header and the banner carry the + // state, so patch them in place. + const conn = root.querySelector(".topbar .conn"); + if (conn) { + conn.textContent = state; + } + const main = root.querySelector("main"); + root.querySelector(".conn-banner")?.remove(); + if (main) { + main.insertAdjacentHTML("afterbegin", connBanner()); + wireConnBanner(); + } + } else if (route !== "signin") { + render(); + } + }, + setOnlineHosts(hosts: OnlineHost[]): void { + online = hosts; + if (route === "pair") { + snapshotDraft(); + if (online.length === 1 && online[0] && draft.hostId === "") { + draft.hostId = online[0].host_id; + } + render(); + } + }, + setPairedHosts(hosts: { host_id: string; label: string }[]): void { + paired = hosts; + if (route === "hosts") { + render(); + } + }, + setPairError(message: string | null): void { + pairError = message; + if (route === "pair") { + snapshotDraft(); + render(); + } + }, + setPairBusy(busy: boolean): void { + pairBusy = busy; + if (route === "pair") { + snapshotDraft(); + render(); + } + }, + ensureTerminal(id: string, cols: number, rows: number): void { + if (route !== "session" || id !== sessionId) { + return; + } + if (terminalView && cols === sessionCols && rows === sessionRows) { + return; + } + teardownSessionWidgetsKeepComposer(); + const pane = root.querySelector("#term-pane"); + if (!pane) { + return; + } + pane.replaceChildren(); + sessionCols = cols; + sessionRows = rows; + terminalView = new TerminalView(terminalFactory, { + onUserInput: (text) => callbacks.onTerminalInput(text), + onFollowChange: ({ following }) => { + if (latestButton) { + latestButton.hidden = following; + } + }, + }); + terminalView.mount(pane, cols, rows); + }, + writeTerminal(text: string): void { + terminalView?.write(text); + }, + clearTerminal(): void { + terminalView?.clear(); + }, + showSessionExited(code: number): void { + const badge = root.querySelector("#session-exit"); + if (badge) { + badge.textContent = `exited (${code})`; + badge.hidden = false; + } + }, + composerOk(req: string): void { + composer?.handleOk(req); + }, + composerError(message: string, req: string | null): void { + composer?.handleError(message, req); + }, + dispose(): void { + globalThis.clearInterval(ticker); + stopStore(); + teardownSessionWidgets(); + composer?.unmount(); + composer = null; + }, + }; + + // Remounting the terminal (a changed host size) must not drop the + // composer's draft, so the composer survives a terminal teardown. + function teardownSessionWidgetsKeepComposer(): void { + terminalView?.dispose(); + terminalView = null; + sessionCols = 0; + sessionRows = 0; + if (latestButton) { + latestButton.hidden = true; + } + } +} diff --git a/apps/remote/web/src/composer.ts b/apps/remote/web/src/composer.ts new file mode 100644 index 0000000..0048753 --- /dev/null +++ b/apps/remote/web/src/composer.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// The composer at the bottom of the session view: a multi-line textarea +// and Send, which ships a prompt frame. On ok the text clears; on error +// the text stays and the message shows. The Builder button belongs to the +// next spec; it fires a callback the shell wires. + +import { newReqId } from "./protocol.js"; + +export interface ComposerCallbacks { + onSend: (text: string, req: string) => void; + onBuilder: () => void; +} + +export class Composer { + private readonly callbacks: ComposerCallbacks; + private area: HTMLTextAreaElement | null = null; + private errorEl: HTMLElement | null = null; + private pendingReq: string | null = null; + + constructor(callbacks: ComposerCallbacks) { + this.callbacks = callbacks; + } + + get awaitingReply(): boolean { + return this.pendingReq !== null; + } + + get currentReq(): string | null { + return this.pendingReq; + } + + mount(parent: HTMLElement): void { + this.unmount(); + const wrap = document.createElement("div"); + wrap.className = "composer"; + const area = document.createElement("textarea"); + area.className = "composer-input"; + area.rows = 2; + area.placeholder = "Message this session…"; + area.setAttribute("aria-label", "Message this session"); + const row = document.createElement("div"); + row.className = "composer-row"; + const errorEl = document.createElement("p"); + errorEl.className = "composer-error"; + errorEl.setAttribute("role", "alert"); + errorEl.hidden = true; + const builder = document.createElement("button"); + builder.type = "button"; + builder.className = "composer-builder"; + builder.textContent = "Builder"; + builder.addEventListener("click", () => this.callbacks.onBuilder()); + const send = document.createElement("button"); + send.type = "button"; + send.className = "composer-send"; + send.textContent = "Send"; + send.addEventListener("click", () => this.submit()); + area.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) { + ev.preventDefault(); + this.submit(); + } + }); + row.append(errorEl, builder, send); + wrap.append(area, row); + parent.appendChild(wrap); + this.area = area; + this.errorEl = errorEl; + } + + submit(): void { + const text = this.area?.value ?? ""; + if (text.trim() === "") { + return; + } + const req = newReqId(); + this.pendingReq = req; + this.hideError(); + this.callbacks.onSend(text, req); + } + + handleOk(req: string): boolean { + if (this.pendingReq === null || this.pendingReq !== req) { + return false; + } + this.pendingReq = null; + if (this.area) { + this.area.value = ""; + } + this.hideError(); + return true; + } + + handleError(message: string, req: string | null): boolean { + if (this.pendingReq === null) { + return false; + } + if (req !== null && req !== this.pendingReq) { + return false; + } + this.pendingReq = null; + this.showError(message); + return true; + } + + getText(): string { + return this.area?.value ?? ""; + } + + unmount(): void { + this.area?.closest(".composer")?.remove(); + this.area = null; + this.errorEl = null; + this.pendingReq = null; + } + + private showError(message: string): void { + if (this.errorEl) { + this.errorEl.textContent = message; + this.errorEl.hidden = false; + } + } + + private hideError(): void { + if (this.errorEl) { + this.errorEl.textContent = ""; + this.errorEl.hidden = true; + } + } +} diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts new file mode 100644 index 0000000..83e729f --- /dev/null +++ b/apps/remote/web/src/fake-host.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Test-only entry behind fake.html (served by vite dev, never built): +// the session shell driven by a fake host over an in-memory channel. +// Playwright drives it through window.__fake. + +import { createShell } from "./app.js"; +import { createChannelHandler } from "./channel.js"; +import { b64encode } from "./crypto.js"; +import { buildList, newReqId, parseOut, parseSessions } from "./protocol.js"; +import { SessionController } from "./session.js"; +import { SessionStore } from "./sessions.js"; +import "./styles.css"; + +const b64 = (text: string): string => b64encode(new TextEncoder().encode(text)); + +const SESSION = { + id: "smoke", + name: "smoke", + cli: "muse", + model: "spark", + workdir: "/home/u/demo", + status: "running", + last_output_at: Math.floor(Date.now() / 1000) - 30, +}; + +function main(): void { + const root = document.getElementById("app"); + if (!root) { + throw new Error("Missing #app element"); + } + const store = new SessionStore(); + const sent: unknown[] = []; + let seq = 7; + let promptError: { code: string; message: string } | null = null; + + const deliver = (frame: unknown): void => { + frames.push(JSON.stringify(frame)); + }; + + const send = (data: string): boolean => { + const frame = JSON.parse(data) as { t: string; id?: string; req?: string; text?: string }; + sent.push(frame); + if (frame.t === "list") { + deliver({ t: "sessions", req: frame.req, items: [SESSION] }); + } else if (frame.t === "attach" && frame.id === "smoke") { + const full = b64("$ muse run\nhello from the host\n"); + deliver({ t: "snapshot", id: "smoke", cols: 80, rows: 24, seq, data: full.slice(0, 10) }); + deliver({ t: "snapshot_more", id: "smoke", data: full.slice(10), last: true }); + } else if (frame.t === "prompt" && typeof frame.req === "string") { + if (promptError) { + deliver({ t: "error", req: frame.req, code: promptError.code, message: promptError.message }); + promptError = null; + } else { + deliver({ t: "ok", req: frame.req }); + } + } + return true; + }; + + const shell = createShell(root, store, { + onPair: () => {}, + onConnect: () => {}, + onDisconnect: () => {}, + onRetry: () => {}, + onPairAgain: () => {}, + onOpenSession: (id) => { + shell.showSession(id, store.get(id)?.name ?? id); + controller.open(id); + }, + onLeaveSession: () => { + controller.close(); + shell.showSessions(); + }, + onKeyPress: (def) => { + controller.sendInputBytes(new Uint8Array(def.bytes)); + }, + onTerminalInput: (text) => { + controller.sendInputText(text); + }, + onComposerSend: (text, req) => { + controller.sendPrompt(text, req); + }, + onNew: () => {}, + onBuilder: () => {}, + }); + + const controller = new SessionController({ + send, + terminal: () => ({ + write: (text) => shell.writeTerminal(text), + clear: () => shell.clearTerminal(), + }), + }); + + const frames = createChannelHandler({ + onSnapshot: (snapshot) => { + shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); + controller.handleSnapshot(snapshot); + }, + onFrame: (frame) => { + const sessions = parseSessions(frame); + if (sessions) { + store.replace(sessions); + return; + } + const out = parseOut(frame); + if (out) { + controller.handleOut(out); + return; + } + const row = frame as { t?: string; req?: string; code?: string; message?: string }; + if (row.t === "ok" && typeof row.req === "string") { + shell.composerOk(row.req); + } else if (row.t === "error" && typeof row.message === "string") { + shell.composerError(row.message, typeof row.req === "string" ? row.req : null); + } + }, + }); + + shell.setLogin("smoke"); + shell.setHostLabel("smoke host"); + shell.setConnectionState("connected"); + shell.showSessions(); + send(buildList(newReqId())); + + window.__fake = { + sent: () => [...sent], + clearSent: () => { + sent.length = 0; + }, + pushOut: (text: string) => { + seq += 1; + deliver({ t: "out", id: "smoke", seq, data: b64(text) }); + }, + failNextPrompt: (code: string, message: string) => { + promptError = { code, message }; + }, + }; +} + +declare global { + interface Window { + __fake: { + sent: () => unknown[]; + clearSent: () => void; + pushOut: (text: string) => void; + failNextPrompt: (code: string, message: string) => void; + }; + } +} + +main(); diff --git a/apps/remote/web/src/keys.ts b/apps/remote/web/src/keys.ts new file mode 100644 index 0000000..5acbd5e --- /dev/null +++ b/apps/remote/web/src/keys.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// The key bar under the terminal: ten keys with the exact bytes each one +// sends as an input frame. Rendering is plain buttons; the bytes table is +// exported so the session layer and the tests share one definition. + +export interface KeyDef { + /** Stable id used in tests and selectors. */ + id: string; + label: string; + ariaLabel: string; + bytes: readonly number[]; +} + +export const KEY_DEFS: readonly KeyDef[] = [ + { id: "esc", label: "Esc", ariaLabel: "Escape", bytes: [0x1b] }, + { id: "tab", label: "Tab", ariaLabel: "Tab", bytes: [0x09] }, + { id: "up", label: "↑", ariaLabel: "Arrow up", bytes: [0x1b, 0x5b, 0x41] }, + { id: "down", label: "↓", ariaLabel: "Arrow down", bytes: [0x1b, 0x5b, 0x42] }, + { id: "left", label: "←", ariaLabel: "Arrow left", bytes: [0x1b, 0x5b, 0x44] }, + { id: "right", label: "→", ariaLabel: "Arrow right", bytes: [0x1b, 0x5b, 0x43] }, + { id: "enter", label: "Enter", ariaLabel: "Enter", bytes: [0x0d] }, + { id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", bytes: [0x03] }, + { id: "y", label: "y", ariaLabel: "Answer y", bytes: [0x79] }, + { id: "n", label: "n", ariaLabel: "Answer n", bytes: [0x6e] }, +]; + +export function keyBytes(def: KeyDef): Uint8Array { + return new Uint8Array(def.bytes); +} + +export function keyById(id: string): KeyDef | null { + return KEY_DEFS.find((def) => def.id === id) ?? null; +} + +export function renderKeyBar(bar: HTMLElement, onKey: (def: KeyDef) => void): void { + bar.replaceChildren(); + bar.setAttribute("role", "toolbar"); + bar.setAttribute("aria-label", "Terminal keys"); + for (const def of KEY_DEFS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "key"; + button.dataset.key = def.id; + button.textContent = def.label; + button.setAttribute("aria-label", def.ariaLabel); + button.addEventListener("click", () => onKey(def)); + bar.appendChild(button); + } +} diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts index 3d95211..75db9ac 100644 --- a/apps/remote/web/src/main.ts +++ b/apps/remote/web/src/main.ts @@ -1,20 +1,39 @@ // SPDX-License-Identifier: Apache-2.0 -// Browser entry point: device key, relay socket, pair form, and the -// reconnecting session connection. Joined snapshots and other protocol -// frames are delivered as `canopy:snapshot` / `canopy:frame` window events -// for the session layer. +// Browser entry point: auth check, device key, relay socket, pairing, +// and the reconnecting host connection behind the session shell. Joined +// snapshots feed the open session; every other protocol frame is routed +// to the session store, the composer, or the window events the Prompt +// Builder (next spec) listens for. +import { createShell } from "./app.js"; +import type { Shell } from "./app.js"; import { createChannelHandler } from "./channel.js"; +import type { JoinedSnapshot } from "./channel.js"; import { generateDeviceKey } from "./crypto.js"; import type { DeviceKey } from "./crypto.js"; +import { keyBytes } from "./keys.js"; +import { + buildList, + newReqId, + parseError, + parseExited, + parseOk, + parseOut, + parseSessionAdded, + parseSessionRemoved, + parseSessions, +} from "./protocol.js"; import { runPairing } from "./pairing.js"; import { ConnectionManager } from "./reconnect.js"; import { RelaySocket, defaultRelayUrl, parseHostsList } from "./relay.js"; import type { OnlineHost } from "./relay.js"; +import { SessionController } from "./session.js"; +import { SessionStore } from "./sessions.js"; import { getHost, listHosts, loadDeviceKey, saveDeviceKey, saveHost } from "./store.js"; import type { HostRecord } from "./store.js"; -import { consumePairFragment, createApp } from "./ui.js"; +import { consumePairFragment } from "./ui.js"; import type { PairSubmit } from "./ui.js"; +import "./styles.css"; async function ensureDeviceKey(): Promise { const existing = await loadDeviceKey(); @@ -26,46 +45,180 @@ async function ensureDeviceKey(): Promise { return fresh; } +// The worker answers 401 without a session cookie, 200 {login} with one. +async function fetchLogin(): Promise { + let res: Response; + try { + res = await globalThis.fetch("/auth/me"); + } catch { + return null; + } + if (res.status === 401) { + return null; + } + if (!res.ok) { + return null; + } + let body: unknown; + try { + body = await res.json(); + } catch { + return null; + } + if (typeof body === "object" && body !== null && typeof (body as { login: unknown }).login === "string") { + return (body as { login: string }).login; + } + return null; +} + async function main(): Promise { const root = document.getElementById("app"); if (!root) { throw new Error("Missing #app element"); } const prefill = consumePairFragment(); - const device = await ensureDeviceKey(); + const login = await fetchLogin(); const relay = new RelaySocket(defaultRelayUrl()); + const store = new SessionStore(); let onlineHosts: OnlineHost[] = []; let manager: ConnectionManager | null = null; - const ui = createApp(root, { - onPair: (submit) => void doPair(submit), - onConnect: (hostId) => void doConnect(hostId), - onDisconnect: () => { - manager?.stop(); - manager = null; - ui.showHosts(); - }, - onRetry: () => manager?.retryNow(), - onPairAgain: () => { - manager?.stop(); - manager = null; - ui.showPair(); + const shell: Shell = createShell( + root, + store, + { + onPair: (submit) => void doPair(submit), + onConnect: (hostId) => void doConnect(hostId), + onDisconnect: () => void doDisconnect(), + onRetry: () => manager?.retryNow(), + onPairAgain: () => { + manager?.stop(); + manager = null; + shell.showPair(); + }, + onOpenSession: (id) => { + shell.showSession(id, store.get(id)?.name ?? id); + controller.open(id); + }, + onLeaveSession: () => { + controller.close(); + shell.showSessions(); + }, + onKeyPress: (def) => { + controller.sendInputBytes(keyBytes(def)); + }, + onTerminalInput: (text) => { + controller.sendInputText(text); + }, + onComposerSend: (text, req) => { + if (!controller.sendPrompt(text, req)) { + shell.composerError("Not connected", req); + } + }, + // The New dialog and the Prompt Builder arrive with the next spec; + // until then the buttons announce themselves on the window. + onNew: () => { + globalThis.dispatchEvent(new CustomEvent("canopy:new")); + }, + onBuilder: () => { + globalThis.dispatchEvent(new CustomEvent("canopy:builder")); + }, }, + ); + + const controller = new SessionController({ + send: (data) => manager?.send(data) ?? false, + terminal: () => ({ + write: (text) => shell.writeTerminal(text), + clear: () => shell.clearTerminal(), + }), + onExited: (_id, code) => shell.showSessionExited(code), }); const refreshPaired = async () => { - ui.setPairedHosts(await listHosts()); + shell.setPairedHosts(await listHosts()); }; + function handleSnapshot(snapshot: JoinedSnapshot): void { + globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); + shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); + controller.handleSnapshot(snapshot); + } + + function handleFrame(frame: unknown): void { + globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); + const sessions = parseSessions(frame); + if (sessions) { + store.replace(sessions); + return; + } + const added = parseSessionAdded(frame); + if (added) { + store.add(added); + return; + } + const removed = parseSessionRemoved(frame); + if (removed !== null) { + store.remove(removed); + return; + } + const exited = parseExited(frame); + if (exited) { + store.markExited(exited.id); + controller.handleExited(exited.id, exited.code); + return; + } + const out = parseOut(frame); + if (out) { + controller.handleOut(out); + return; + } + const ok = parseOk(frame); + if (ok !== null) { + shell.composerOk(ok); + return; + } + const error = parseError(frame); + if (error) { + shell.composerError(error.message, error.req); + } + } + + function startSession(host: HostRecord): void { + manager?.stop(); + controller.close(); + shell.setHostLabel(host.label); + shell.showSessions(); + const frames = createChannelHandler({ + onSnapshot: handleSnapshot, + onFrame: handleFrame, + }); + const next = new ConnectionManager({ relay, host, device }); + manager = next; + next.onState((state) => { + shell.setConnectionState(state); + if (state === "connected") { + // A fresh connect and a reconnect both land here: refresh the + // list, and the open session re-attaches for a fresh snapshot. + next.send(buildList(newReqId())); + controller.reattach(); + } + }); + next.onMessage((data) => frames.push(data)); + next.start(); + } + + const device = await ensureDeviceKey(); + async function doPair(submit: PairSubmit): Promise { - ui.setPairBusy(true); - ui.setPairError(null); + shell.setPairBusy(true); + shell.setPairError(null); try { await relay.connect(); } catch { - ui.setPairBusy(false); - ui.setPairError("Could not reach the relay"); + shell.setPairBusy(false); + shell.setPairError("Could not reach the relay"); return; } const picked = onlineHosts.find((host) => host.host_id === submit.hostId); @@ -84,9 +237,9 @@ async function main(): Promise { await refreshPaired(); startSession(host); } catch (err) { - ui.setPairError(err instanceof Error ? err.message : "Pairing failed"); + shell.setPairError(err instanceof Error ? err.message : "Pairing failed"); } finally { - ui.setPairBusy(false); + shell.setPairBusy(false); } } @@ -94,28 +247,23 @@ async function main(): Promise { const host = await getHost(hostId); if (!host) { await refreshPaired(); - ui.showHosts(); + shell.showHosts(); return; } startSession(host); } - function startSession(host: HostRecord): void { + async function doDisconnect(): Promise { manager?.stop(); - ui.showSession(host.label); - const frames = createChannelHandler({ - onSnapshot: (snapshot) => { - globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); - }, - onFrame: (frame) => { - globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); - }, - }); - const next = new ConnectionManager({ relay, host, device }); - manager = next; - next.onState((state) => ui.setConnectionState(state)); - next.onMessage((data) => frames.push(data)); - next.start(); + manager = null; + controller.close(); + const stored = await listHosts(); + await refreshPaired(); + if (stored.length === 0) { + shell.showPair(); + } else { + shell.showHosts(); + } } relay.onFrame((frame) => { @@ -125,23 +273,29 @@ async function main(): Promise { const hosts = parseHostsList(frame.body); if (hosts) { onlineHosts = hosts; - ui.setOnlineHosts(hosts); + shell.setOnlineHosts(hosts); } }); + if (login === null) { + shell.showSignIn(); + return; + } + shell.setLogin(login); await refreshPaired(); - if (prefill !== null) { - ui.showPair(prefill); - } else if ((await listHosts()).length > 0) { - ui.showHosts(); + const stored = await listHosts(); + if (prefill !== null || stored.length === 0) { + shell.showPair(prefill ?? undefined); + } else if (stored.length === 1 && stored[0]) { + startSession(stored[0]); } else { - ui.showPair(); + shell.showHosts(); } try { await relay.connect(); } catch { - ui.setPairError("Could not reach the relay"); + shell.setPairError("Could not reach the relay"); } } diff --git a/apps/remote/web/src/peer.ts b/apps/remote/web/src/peer.ts index 9eafa64..6e86e1d 100644 --- a/apps/remote/web/src/peer.ts +++ b/apps/remote/web/src/peer.ts @@ -223,6 +223,20 @@ export class HostPeer { } } + // Session-layer frames to the host. False when there is no open data + // channel; the caller decides whether to retry or drop. + send(data: string): boolean { + if (this.state !== "open" || !this.channel || this.channel.readyState !== "open") { + return false; + } + try { + this.channel.send(data); + return true; + } catch { + return false; + } + } + close(): void { if (this.state === "starting") { this.settleOpen?.reject(new Error("Connection closed")); diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts new file mode 100644 index 0000000..9e9f77e --- /dev/null +++ b/apps/remote/web/src/protocol.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 +// Session protocol v1 over the data channel: frame builders for the +// requests the session view sends, strict parsers for the frames it +// receives, and the display helpers the session list needs. Pure module: +// no DOM, no network. Payload bytes are base64 on the wire. + +import { b64decode, b64encode } from "./crypto.js"; + +export interface SessionItem { + id: string; + name: string; + cli: string; + model: string; + workdir: string; + status: string; + /** Last output time as epoch milliseconds, or null when unknown. */ + lastOutputAt: number | null; +} + +export interface OutFrame { + id: string; + seq: number; + data: Uint8Array; +} + +export interface ReplyError { + req: string | null; + code: string; + message: string; +} + +function isRecord(frame: unknown): frame is Record { + return typeof frame === "object" && frame !== null; +} + +function frameType(frame: unknown): string | null { + if (!isRecord(frame) || typeof frame.t !== "string") { + return null; + } + return frame.t; +} + +// last_output_at arrives as unix seconds (number), unix milliseconds, an +// ISO string, or nothing at all; normalize to epoch milliseconds. +function parseLastOutputAt(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value < 1e12 ? Math.floor(value * 1000) : Math.floor(value); + } + if (typeof value === "string" && value !== "") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function parseSessionItem(item: unknown): SessionItem | null { + if (!isRecord(item) || typeof item.id !== "string" || item.id === "") { + return null; + } + return { + id: item.id, + name: asString(item.name), + cli: asString(item.cli), + model: asString(item.model), + workdir: asString(item.workdir), + status: asString(item.status), + lastOutputAt: parseLastOutputAt(item.last_output_at), + }; +} + +export function parseSessions(frame: unknown): SessionItem[] | null { + if (frameType(frame) !== "sessions" || !isRecord(frame) || !Array.isArray(frame.items)) { + return null; + } + const items: SessionItem[] = []; + for (const item of frame.items) { + const parsed = parseSessionItem(item); + if (parsed) { + items.push(parsed); + } + } + return items; +} + +export function parseSessionAdded(frame: unknown): SessionItem | null { + if (frameType(frame) !== "session_added" || !isRecord(frame)) { + return null; + } + return parseSessionItem(frame.item); +} + +export function parseSessionRemoved(frame: unknown): string | null { + if (frameType(frame) !== "session_removed" || !isRecord(frame)) { + return null; + } + return typeof frame.id === "string" ? frame.id : null; +} + +export function parseExited(frame: unknown): { id: string; code: number } | null { + if (frameType(frame) !== "exited" || !isRecord(frame)) { + return null; + } + if (typeof frame.id !== "string" || typeof frame.code !== "number") { + return null; + } + return { id: frame.id, code: frame.code }; +} + +function b64Bytes(data: string): Uint8Array | null { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) { + return null; + } + try { + return b64decode(data); + } catch { + return null; + } +} + +export function parseOut(frame: unknown): OutFrame | null { + if (frameType(frame) !== "out" || !isRecord(frame)) { + return null; + } + if (typeof frame.id !== "string" || typeof frame.seq !== "number" || typeof frame.data !== "string") { + return null; + } + const data = b64Bytes(frame.data); + if (!data) { + return null; + } + return { id: frame.id, seq: frame.seq, data }; +} + +export function decodeSnapshotData(data: string): Uint8Array | null { + return b64Bytes(data); +} + +export function decodeUtf8(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +export function parseOk(frame: unknown): string | null { + if (frameType(frame) !== "ok" || !isRecord(frame)) { + return null; + } + return typeof frame.req === "string" ? frame.req : null; +} + +export function parseError(frame: unknown): ReplyError | null { + if (frameType(frame) !== "error" || !isRecord(frame)) { + return null; + } + if (typeof frame.code !== "string" || typeof frame.message !== "string") { + return null; + } + return { req: typeof frame.req === "string" ? frame.req : null, code: frame.code, message: frame.message }; +} + +export function buildList(req: string): string { + return JSON.stringify({ t: "list", req }); +} + +export function buildAttach(id: string): string { + return JSON.stringify({ t: "attach", id }); +} + +export function buildDetach(id: string): string { + return JSON.stringify({ t: "detach", id }); +} + +export function buildInput(id: string, bytes: Uint8Array): string { + return JSON.stringify({ t: "input", id, data: b64encode(bytes) }); +} + +export function buildInputText(id: string, text: string): string { + return buildInput(id, new TextEncoder().encode(text)); +} + +export function buildPrompt(req: string, id: string, text: string): string { + return JSON.stringify({ t: "prompt", req, id, text }); +} + +let reqCounter = 0; + +export function newReqId(): string { + reqCounter += 1; + return `r${Date.now().toString(36)}-${reqCounter.toString(36)}`; +} + +export function workdirBasename(workdir: string): string { + const trimmed = workdir.replace(/\/+$/, ""); + if (trimmed === "") { + return ""; + } + const slash = trimmed.lastIndexOf("/"); + return slash < 0 ? trimmed : trimmed.slice(slash + 1); +} + +export function formatLastOutput(nowMs: number, lastMs: number | null): string { + if (lastMs === null) { + return "no output yet"; + } + const elapsed = Math.max(0, Math.floor((nowMs - lastMs) / 1000)); + if (elapsed < 60) { + return `last output ${elapsed}s ago`; + } + const minutes = Math.floor(elapsed / 60); + if (minutes < 60) { + return `last output ${minutes}min ago`; + } + return `last output ${Math.floor(minutes / 60)}h ago`; +} + +// The status dot: green while the session runs, amber while it idles, +// grey once it exited. Unknown statuses get the default dot. +const STATUS_DOT: Record = { + running: "run", + busy: "run", + idle: "idle", + waiting: "idle", + exited: "dead", + dead: "dead", +}; + +export function statusDot(status: string): string { + return STATUS_DOT[status] ?? "unknown"; +} diff --git a/apps/remote/web/src/reconnect.ts b/apps/remote/web/src/reconnect.ts index 4c2968e..7a24ed1 100644 --- a/apps/remote/web/src/reconnect.ts +++ b/apps/remote/web/src/reconnect.ts @@ -96,6 +96,18 @@ export class ConnectionManager { void this.attempt(); } + get connected(): boolean { + return this.phase === "open"; + } + + // Session-layer frames to the host. False while reconnecting or failed. + send(data: string): boolean { + if (this.phase !== "open" || !this.peer) { + return false; + } + return this.peer.send(data); + } + stop(): void { this.clearTimers(); this.unsubscribePeer?.(); diff --git a/apps/remote/web/src/session.ts b/apps/remote/web/src/session.ts new file mode 100644 index 0000000..c026a2f --- /dev/null +++ b/apps/remote/web/src/session.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// The open session: attach on open, detach on leave, and the terminal +// feed. The snapshot redraws the terminal and sets the sequence floor; +// only out frames with a greater seq are applied, so a reconnect's fresh +// snapshot never duplicates output. Out frames that arrive before the +// snapshot wait in a bounded buffer instead of racing it. + +import type { JoinedSnapshot } from "./channel.js"; +import { + buildAttach, + buildDetach, + buildInput, + buildInputText, + buildPrompt, + decodeSnapshotData, + decodeUtf8, +} from "./protocol.js"; +import type { OutFrame } from "./protocol.js"; + +export interface SessionTerminal { + write: (text: string) => void; + clear: () => void; +} + +export interface SessionCallbacks { + send: (data: string) => boolean; + terminal: () => SessionTerminal | null; + onExited?: (id: string, code: number) => void; +} + +const MAX_PENDING_OUT = 200; + +export class SessionController { + private readonly callbacks: SessionCallbacks; + private openId: string | null = null; + private lastSeq = -1; + private snapshotSeen = false; + private pending: OutFrame[] = []; + + constructor(callbacks: SessionCallbacks) { + this.callbacks = callbacks; + } + + get sessionId(): string | null { + return this.openId; + } + + open(id: string): void { + if (this.openId !== null && this.openId !== id) { + this.callbacks.send(buildDetach(this.openId)); + } + this.openId = id; + this.lastSeq = -1; + this.snapshotSeen = false; + this.pending = []; + this.callbacks.send(buildAttach(id)); + } + + // After a reconnect the same attach runs again; the fresh snapshot + // redraws the terminal and its seq drops every stale out frame. + reattach(): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildAttach(this.openId)); + } + + close(): void { + if (this.openId !== null) { + this.callbacks.send(buildDetach(this.openId)); + } + this.openId = null; + this.lastSeq = -1; + this.snapshotSeen = false; + this.pending = []; + } + + sendInputText(text: string): boolean { + if (this.openId === null || text === "") { + return false; + } + return this.callbacks.send(buildInputText(this.openId, text)); + } + + sendInputBytes(bytes: Uint8Array): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildInput(this.openId, bytes)); + } + + sendPrompt(text: string, req: string): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildPrompt(req, this.openId, text)); + } + + handleSnapshot(snapshot: JoinedSnapshot): void { + if (this.openId === null || snapshot.id !== this.openId) { + return; + } + const terminal = this.callbacks.terminal(); + if (!terminal) { + return; + } + const bytes = decodeSnapshotData(snapshot.data); + if (!bytes) { + return; + } + terminal.clear(); + this.lastSeq = snapshot.seq; + this.snapshotSeen = true; + terminal.write(decodeUtf8(bytes)); + this.drainPending(terminal); + } + + handleOut(frame: OutFrame): void { + if (this.openId === null || frame.id !== this.openId) { + return; + } + if (!this.snapshotSeen) { + this.pending.push(frame); + if (this.pending.length > MAX_PENDING_OUT) { + this.pending.splice(0, this.pending.length - MAX_PENDING_OUT); + } + return; + } + if (frame.seq <= this.lastSeq) { + return; + } + this.callbacks.terminal()?.write(decodeUtf8(frame.data)); + this.lastSeq = frame.seq; + } + + handleExited(id: string, code: number): void { + if (this.openId === null || id !== this.openId) { + return; + } + this.callbacks.onExited?.(id, code); + } + + private drainPending(terminal: SessionTerminal): void { + const frames = this.pending + .filter((frame) => frame.seq > this.lastSeq) + .sort((a, b) => a.seq - b.seq); + this.pending = []; + for (const frame of frames) { + terminal.write(decodeUtf8(frame.data)); + this.lastSeq = frame.seq; + } + } +} diff --git a/apps/remote/web/src/sessions.ts b/apps/remote/web/src/sessions.ts new file mode 100644 index 0000000..cb48fed --- /dev/null +++ b/apps/remote/web/src/sessions.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// The session list: a store fed by the sessions frame and the +// session_added / session_removed / exited events, plus the DOM renderer +// the Sessions route uses. An exited session keeps its row with a dead +// dot until the host removes it. + +import { formatLastOutput, statusDot, workdirBasename } from "./protocol.js"; +import type { SessionItem } from "./protocol.js"; + +export class SessionStore { + private readonly items = new Map(); + private readonly listeners = new Set<() => void>(); + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + replace(items: SessionItem[]): void { + this.items.clear(); + for (const item of items) { + this.items.set(item.id, item); + } + this.notify(); + } + + add(item: SessionItem): void { + this.items.set(item.id, item); + this.notify(); + } + + remove(id: string): void { + if (this.items.delete(id)) { + this.notify(); + } + } + + markExited(id: string): void { + const item = this.items.get(id); + if (item && item.status !== "exited") { + this.items.set(id, { ...item, status: "exited" }); + this.notify(); + } + } + + get(id: string): SessionItem | null { + return this.items.get(id) ?? null; + } + + list(): SessionItem[] { + return [...this.items.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + get size(): number { + return this.items.size; + } +} + +export interface SessionListCallbacks { + onOpen: (id: string) => void; + onNew: () => void; +} + +function esc(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// nowMs is injected so tests pin the relative times. +export function renderSessions( + root: HTMLElement, + items: SessionItem[], + nowMs: number, + callbacks: SessionListCallbacks, +): void { + const rows = items + .map((item) => { + const cliModel = [item.cli, item.model].filter((part) => part !== "").join(" · "); + const base = workdirBasename(item.workdir); + const sub = [cliModel, base].filter((part) => part !== "").join(" · "); + return ( + `
  • ` + ); + }) + .join(""); + root.innerHTML = + `

    Sessions

    ` + + `
    ` + + (items.length === 0 + ? `

    No sessions on this host.

    ` + : `
      ${rows}
    `); + root.querySelector("#session-new")?.addEventListener("click", () => callbacks.onNew()); + for (const button of root.querySelectorAll("[data-session]")) { + button.addEventListener("click", () => { + const id = button.getAttribute("data-session"); + if (id) { + callbacks.onOpen(id); + } + }); + } +} diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css new file mode 100644 index 0000000..0b4e69e --- /dev/null +++ b/apps/remote/web/src/styles.css @@ -0,0 +1,406 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Canopy remote: every color, font and spacing decision lives in these + * custom properties so the visual identity review can retheme without + * touching the components. Light and dark follow the OS setting. */ + +:root { + --bg: #ffffff; + --ink: #1d1510; + --muted: #6b6259; + --line: #e3ddd4; + --accent: #0e7c76; + --accent-ink: #ffffff; + --danger: #a4262c; + --dot-run: #1e9e50; + --dot-idle: #c77700; + --dot-dead: #8a8378; + --dot-unknown: #b3aa9d; + --term-bg: #101210; + --key-bg: #f1ede6; + --key-ink: #1d1510; + --radius: 8px; + --gap: 0.5rem; + --font-body: system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "Sometype Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #222928; + --ink: #ecebe4; + --muted: #a8a49a; + --line: #3a423f; + --accent: #3fb8b0; + --accent-ink: #10201e; + --danger: #e08086; + --dot-run: #3fd07a; + --dot-idle: #e09a2b; + --dot-dead: #6f6a60; + --dot-unknown: #57534b; + --term-bg: #0c0c0c; + --key-bg: #323937; + --key-ink: #ecebe4; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + overflow-x: hidden; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: var(--font-body); + line-height: 1.5; + padding-bottom: env(safe-area-inset-bottom); +} + +#app { + margin: 0 auto; + max-width: 48rem; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +main { + padding: 0 1rem 1rem; + padding-left: max(1rem, env(safe-area-inset-left)); + padding-right: max(1rem, env(safe-area-inset-right)); +} + +button { + font: inherit; + color: var(--ink); + background: var(--key-bg); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.5rem 0.9rem; + min-height: 2.75rem; +} + +button:active { + filter: brightness(0.94); +} + +input, +select, +textarea { + font: inherit; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +input, +select { + display: block; + width: 100%; + margin-top: 0.25rem; + padding: 0.5rem 0.6rem; + min-height: 2.75rem; +} + +label { + display: block; + margin: 0.75rem 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.6rem 1rem; + padding-top: max(0.6rem, env(safe-area-inset-top)); + background: var(--bg); + border-bottom: 1px solid var(--line); + overflow: hidden; + white-space: nowrap; +} + +.topbar .brand { + font-weight: 700; +} + +.topbar .login, +.topbar .host { + overflow: hidden; + text-overflow: ellipsis; +} + +.topbar .login { + color: var(--muted); +} + +.topbar .conn { + margin-left: auto; + font-size: 0.85rem; + color: var(--muted); + flex-shrink: 0; +} + +.conn-banner { + background: var(--key-bg); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.conn-banner button { + min-height: 2.25rem; + padding: 0.25rem 0.75rem; +} + +.signin { + margin: auto; + text-align: center; + max-width: 24rem; +} + +#pair-error:empty { + display: none; +} + +#pair-error { + color: var(--danger); +} + +.sessions-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.sessions-head h2 { + margin: 0.75rem 0; +} + +.session-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--gap); +} + +.session-row { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + text-align: left; + background: var(--bg); + min-height: 3.5rem; +} + +.session-main { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.session-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-sub { + font-size: 0.85rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-age { + font-size: 0.8rem; + color: var(--muted); + flex-shrink: 0; +} + +.dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 50%; + flex-shrink: 0; + background: var(--dot-unknown); +} + +.dot-run { + background: var(--dot-run); +} + +.dot-idle { + background: var(--dot-idle); +} + +.dot-dead { + background: var(--dot-dead); +} + +.empty { + color: var(--muted); +} + +.session-view { + display: flex; + flex-direction: column; + gap: var(--gap); + flex: 1; + min-height: 0; +} + +.session-bar { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.session-bar #session-back { + min-width: 2.75rem; +} + +.session-title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-exit { + font-size: 0.8rem; + color: var(--danger); + flex-shrink: 0; +} + +.session-exit[hidden] { + display: none; +} + +.term-pane { + background: var(--term-bg); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + min-height: 16rem; + height: 46dvh; +} + +.term-placeholder { + color: var(--muted); + padding: 1rem; +} + +.term-fit { + width: 100%; + height: 100%; + overflow: hidden; + /* Single-finger pans the xterm viewport natively; pinch reaches the + * zoom handler instead of the browser. */ + touch-action: pan-x pan-y; +} + +.term-scale { + transform-origin: top left; +} + +.term-controls { + display: flex; + gap: var(--gap); +} + +.term-controls button { + min-width: 2.75rem; +} + +#term-latest { + margin-left: auto; +} + +#term-latest[hidden] { + display: none; +} + +.keybar { + display: flex; + gap: var(--gap); + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.keybar .key { + flex: 1 0 auto; + min-width: 3rem; + font-family: var(--font-mono); +} + +.composer { + display: flex; + flex-direction: column; + gap: var(--gap); +} + +.composer-input { + width: 100%; + resize: vertical; + min-height: 3.5rem; + padding: 0.5rem 0.6rem; +} + +.composer-row { + display: flex; + gap: var(--gap); + align-items: center; +} + +.composer-error { + flex: 1; + margin: 0; + font-size: 0.85rem; + color: var(--danger); + overflow-wrap: anywhere; +} + +.composer-error[hidden] { + display: none; +} + +.composer-send { + background: var(--accent); + color: var(--accent-ink); + border-color: transparent; + margin-left: auto; +} + +@media (min-width: 48rem) { + .term-pane { + height: 60dvh; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts new file mode 100644 index 0000000..283c286 --- /dev/null +++ b/apps/remote/web/src/terminal.ts @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// The host PTY on the phone: an xterm.js terminal created once with the +// snapshot's cols and rows — the browser never resizes the host — scaled +// with a CSS transform so its full width fits the screen. Pinch or the +// +/− buttons zoom, scrolling stays inside xterm's own viewport, output +// follows the bottom unless the user scrolled up, and tapping the +// terminal focuses xterm's hidden textarea so the soft keyboard opens and +// typed characters come back through onData as input. + +import "@xterm/xterm/css/xterm.css"; +import { Terminal } from "@xterm/xterm"; + +export type TerminalTheme = "light" | "dark"; + +export interface FollowState { + following: boolean; +} + +export interface TerminalHandle { + write: (text: string) => void; + clear: () => void; + focus: () => void; + scrollToBottom: () => void; + isAtBottom: () => boolean; + onUserInput: (callback: (text: string) => void) => () => void; + onScroll: (callback: () => void) => () => void; + dispose: () => void; +} + +export type TerminalFactory = (host: HTMLElement, cols: number, rows: number) => TerminalHandle; + +export const MIN_ZOOM = 0.4; +export const MAX_ZOOM = 3; +const ZOOM_STEP = 1.25; + +export function clampZoom(zoom: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); +} + +// Base scale so the terminal's full width fits the pane, times the zoom. +export function computeScale(paneWidth: number, contentWidth: number, zoom: number): number { + if (paneWidth <= 0 || contentWidth <= 0) { + return 1; + } + return (paneWidth / contentWidth) * clampZoom(zoom); +} + +export function zoomFromPinch(startDistance: number, currentDistance: number, startZoom: number): number { + if (startDistance <= 0) { + return startZoom; + } + return clampZoom(startZoom * (currentDistance / startDistance)); +} + +function touchDistance(a: Touch, b: Touch): number { + return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); +} + +function systemTheme(): TerminalTheme { + if (typeof globalThis.matchMedia === "function") { + return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + return "dark"; +} + +const THEMES = { + dark: { background: "#0c0c0c", foreground: "#e8e6e1", cursor: "#3fb8b0" }, + light: { background: "#faf8f4", foreground: "#1d1510", cursor: "#0e7c76" }, +} as const; + +export function createXtermHandle( + host: HTMLElement, + cols: number, + rows: number, + theme?: TerminalTheme, +): TerminalHandle { + const terminal = new Terminal({ + cols, + rows, + scrollback: 2000, + fontSize: 14, + fontFamily: `"Sometype Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`, + theme: { ...THEMES[theme ?? systemTheme()] }, + }); + terminal.open(host); + return { + write: (text) => terminal.write(text), + clear: () => terminal.clear(), + focus: () => terminal.focus(), + scrollToBottom: () => terminal.scrollToBottom(), + isAtBottom: () => { + const buffer = terminal.buffer.active; + return buffer.viewportY >= buffer.baseY; + }, + onUserInput: (callback) => { + const sub = terminal.onData(callback); + return () => sub.dispose(); + }, + onScroll: (callback) => { + const sub = terminal.onScroll(() => callback()); + return () => sub.dispose(); + }, + dispose: () => terminal.dispose(), + }; +} + +export interface TerminalViewCallbacks { + onUserInput: (text: string) => void; + onFollowChange?: (state: FollowState) => void; +} + +export class TerminalView { + private readonly factory: TerminalFactory; + private readonly callbacks: TerminalViewCallbacks; + private fit: HTMLElement | null = null; + private scaler: HTMLElement | null = null; + private host: HTMLElement | null = null; + private handle: TerminalHandle | null = null; + private zoom = 1; + private following = true; + private stopObserving: (() => void) | null = null; + private pinchStart: { distance: number; zoom: number } | null = null; + + constructor(factory: TerminalFactory, callbacks: TerminalViewCallbacks) { + this.factory = factory; + this.callbacks = callbacks; + } + + // The scaffold: .term-fit (the pane, clips) > .term-scale (the CSS + // transform) > .term-host (xterm's own fixed pixel size). + mount(parent: HTMLElement, cols: number, rows: number): void { + this.dispose(); + const fit = document.createElement("div"); + fit.className = "term-fit"; + const scaler = document.createElement("div"); + scaler.className = "term-scale"; + const host = document.createElement("div"); + host.className = "term-host"; + scaler.appendChild(host); + fit.appendChild(scaler); + parent.appendChild(fit); + this.fit = fit; + this.scaler = scaler; + this.host = host; + this.handle = this.factory(host, cols, rows); + this.handle.onUserInput((text) => this.callbacks.onUserInput(text)); + this.handle.onScroll(() => this.refreshFollow()); + host.addEventListener("click", () => this.handle?.focus()); + fit.addEventListener("touchstart", (ev) => this.onTouchStart(ev), { passive: true }); + fit.addEventListener("touchmove", (ev) => this.onTouchMove(ev), { passive: true }); + fit.addEventListener("touchend", () => { + this.pinchStart = null; + }); + const refit = () => this.refit(); + if (typeof ResizeObserver !== "undefined" && fit) { + const observer = new ResizeObserver(refit); + observer.observe(fit); + this.stopObserving = () => observer.disconnect(); + } else { + globalThis.addEventListener("resize", refit); + this.stopObserving = () => globalThis.removeEventListener("resize", refit); + } + if (typeof globalThis.requestAnimationFrame === "function") { + globalThis.requestAnimationFrame(refit); + } + if (typeof document !== "undefined" && "fonts" in document) { + void document.fonts.ready.then(refit); + } + this.refreshFollow(); + } + + get fitted(): boolean { + return this.handle !== null; + } + + get currentZoom(): number { + return this.zoom; + } + + get isFollowing(): boolean { + return this.following; + } + + write(text: string): void { + this.handle?.write(text); + } + + clear(): void { + this.handle?.clear(); + } + + focus(): void { + this.handle?.focus(); + } + + setZoom(zoom: number): void { + this.zoom = clampZoom(zoom); + this.refit(); + } + + zoomIn(): void { + this.setZoom(this.zoom * ZOOM_STEP); + } + + zoomOut(): void { + this.setZoom(this.zoom / ZOOM_STEP); + } + + scrollToLatest(): void { + this.handle?.scrollToBottom(); + this.setFollowing(true); + } + + refit(): void { + const fit = this.fit; + const scaler = this.scaler; + const host = this.host; + if (!fit || !scaler || !host) { + return; + } + const content = host.firstElementChild ?? host; + const scale = computeScale(fit.clientWidth, content.scrollWidth, this.zoom); + scaler.style.transform = `scale(${scale})`; + scaler.style.width = `${content.scrollWidth * scale}px`; + scaler.style.height = `${content.scrollHeight * scale}px`; + } + + dispose(): void { + this.stopObserving?.(); + this.stopObserving = null; + this.handle?.dispose(); + this.handle = null; + this.fit?.remove(); + this.fit = null; + this.scaler = null; + this.host = null; + this.pinchStart = null; + this.following = true; + } + + private refreshFollow(): void { + const atBottom = this.handle?.isAtBottom() ?? true; + this.setFollowing(atBottom); + } + + private setFollowing(following: boolean): void { + if (this.following === following) { + return; + } + this.following = following; + this.callbacks.onFollowChange?.({ following }); + } + + private onTouchStart(ev: TouchEvent): void { + if (ev.touches.length === 2) { + const [a, b] = [ev.touches[0], ev.touches[1]]; + if (a && b) { + this.pinchStart = { distance: touchDistance(a, b), zoom: this.zoom }; + } + } + } + + private onTouchMove(ev: TouchEvent): void { + if (ev.touches.length !== 2 || !this.pinchStart) { + return; + } + const [a, b] = [ev.touches[0], ev.touches[1]]; + if (!a || !b) { + return; + } + this.setZoom(zoomFromPinch(this.pinchStart.distance, touchDistance(a, b), this.pinchStart.zoom)); + } +} diff --git a/apps/remote/web/src/vite-env.d.ts b/apps/remote/web/src/vite-env.d.ts new file mode 100644 index 0000000..a6b6b42 --- /dev/null +++ b/apps/remote/web/src/vite-env.d.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: Apache-2.0 +/// diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts new file mode 100644 index 0000000..849922d --- /dev/null +++ b/apps/remote/web/test/app.test.ts @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createShell } from "../src/app.js"; +import type { Shell, ShellCallbacks } from "../src/app.js"; +import { keyBytes } from "../src/keys.js"; +import type { KeyDef } from "../src/keys.js"; +import { connectFailMessage } from "../src/peer.js"; +import { SessionStore } from "../src/sessions.js"; +import type { TerminalFactory, TerminalHandle } from "../src/terminal.js"; +import type { PairSubmit } from "../src/ui.js"; + +class FakeHandle implements TerminalHandle { + written: string[] = []; + clears = 0; + disposed = false; + atBottom = true; + cols: number; + rows: number; + private inputListeners: ((text: string) => void)[] = []; + private scrollListeners: (() => void)[] = []; + + constructor(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + } + + write = (text: string): void => { + this.written.push(text); + }; + clear = (): void => { + this.clears += 1; + }; + focus = (): void => {}; + scrollToBottom = (): void => { + this.atBottom = true; + }; + isAtBottom = (): boolean => this.atBottom; + onUserInput = (callback: (text: string) => void): (() => void) => { + this.inputListeners.push(callback); + return () => {}; + }; + onScroll = (callback: () => void): (() => void) => { + this.scrollListeners.push(callback); + return () => {}; + }; + dispose = (): void => { + this.disposed = true; + }; + + emitInput(text: string): void { + for (const listener of this.inputListeners) { + listener(text); + } + } + + emitScroll(atBottom: boolean): void { + this.atBottom = atBottom; + for (const listener of this.scrollListeners) { + listener(); + } + } +} + +describe("createShell", () => { + let root: HTMLDivElement; + let store: SessionStore; + let shell: Shell; + let calls: ShellCallbacks & { + pairs: PairSubmit[]; + keys: KeyDef[]; + inputs: string[]; + composerSends: { text: string; req: string }[]; + }; + let handles: FakeHandle[]; + + const query = (selector: string): T => { + const el = root.querySelector(selector); + if (!el) { + throw new Error(`missing ${selector}`); + } + return el as T; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-27T00:00:00.000Z")); + root = document.createElement("div"); + document.body.appendChild(root); + store = new SessionStore(); + handles = []; + const factory: TerminalFactory = (_host, cols, rows) => { + const handle = new FakeHandle(cols, rows); + handles.push(handle); + return handle; + }; + const pairs: PairSubmit[] = []; + const keys: KeyDef[] = []; + const inputs: string[] = []; + const composerSends: { text: string; req: string }[] = []; + const signal = { + connected: [] as string[], + disconnects: 0, + retries: 0, + pairAgains: 0, + opened: [] as string[], + left: 0, + news: 0, + builders: 0, + }; + calls = { + pairs, + keys, + inputs, + composerSends, + onPair: (submit) => pairs.push(submit), + onConnect: (hostId) => signal.connected.push(hostId), + onDisconnect: () => { + signal.disconnects += 1; + }, + onRetry: () => { + signal.retries += 1; + }, + onPairAgain: () => { + signal.pairAgains += 1; + }, + onOpenSession: (id) => signal.opened.push(id), + onLeaveSession: () => { + signal.left += 1; + }, + onKeyPress: (def) => keys.push(def), + onTerminalInput: (text) => inputs.push(text), + onComposerSend: (text, req) => composerSends.push({ text, req }), + onNew: () => { + signal.news += 1; + }, + onBuilder: () => { + signal.builders += 1; + }, + }; + ( + calls as unknown as { + signal: typeof signal; + } + ).signal = signal; + shell = createShell(root, store, calls, { terminalFactory: factory }); + }); + + afterEach(() => { + shell.dispose(); + root.remove(); + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + const signal = () => + ( + calls as unknown as { + signal: { + connected: string[]; + disconnects: number; + retries: number; + pairAgains: number; + opened: string[]; + left: number; + news: number; + builders: number; + }; + } + ).signal; + + it("shows the sign-in button", () => { + shell.showSignIn(); + const button = query("#signin-go"); + expect(button.textContent).toBe("Sign in with GitHub"); + }); + + it("shows the pair form with the header once logged in", () => { + shell.setLogin("octo"); + shell.showPair("7K3QH-M9P2X"); + expect(query(".topbar .login").textContent).toBe("octo"); + expect(query("#pair-code").value).toBe("7K3QH-M9P2X"); + shell.setOnlineHosts([{ host_id: "h1", label: "Host one" }]); + query("#pair-code").value = "7K3QH-M9P2X"; + query("#pair-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(calls.pairs).toHaveLength(1); + expect(calls.pairs[0]?.hostId).toBe("h1"); + }); + + it("shows the host picker with connect buttons", () => { + shell.setLogin("octo"); + shell.setPairedHosts([ + { host_id: "h1", label: "Host one" }, + { host_id: "h2", label: "Host two" }, + ]); + shell.showHosts(); + const buttons = [...root.querySelectorAll("[data-connect]")]; + expect(buttons).toHaveLength(2); + buttons[0]?.click(); + expect(signal().connected).toEqual(["h1"]); + }); + + it("renders the sessions route with header, list and actions", () => { + shell.setLogin("octo"); + shell.setHostLabel("Host one"); + shell.setConnectionState("connected"); + store.replace([ + { + id: "s1", + name: "build", + cli: "muse", + model: "spark", + workdir: "/home/u/proj", + status: "running", + lastOutputAt: Date.now() - 30_000, + }, + ]); + shell.showSessions(); + expect(query(".topbar .login").textContent).toBe("octo"); + expect(query(".topbar .host").textContent).toBe("Host one"); + expect(query(".topbar .conn").textContent).toBe("connected"); + const row = query('[data-session="s1"]'); + expect(row.querySelector(".session-name")?.textContent).toBe("build"); + row.click(); + expect(signal().opened).toEqual(["s1"]); + query("#session-new").click(); + expect(signal().news).toBe(1); + query("#conn-disconnect").click(); + expect(signal().disconnects).toBe(1); + }); + + it("updates the list when the store changes", () => { + shell.showSessions(); + expect(root.querySelector(".session-list")).toBeNull(); + store.add({ + id: "s1", + name: "build", + cli: "", + model: "", + workdir: "", + status: "running", + lastOutputAt: null, + }); + expect(root.querySelector('[data-session="s1"]')).not.toBeNull(); + }); + + it("opens the session view with key bar and composer", () => { + shell.showSession("s1", "build"); + expect(query(".session-title").textContent).toBe("build"); + const keys = [...root.querySelectorAll("#keybar .key")]; + expect(keys).toHaveLength(10); + keys[0]?.click(); + expect(calls.keys.map((def) => def.id)).toEqual(["esc"]); + expect([...keyBytes(calls.keys[0] as KeyDef)]).toEqual([0x1b]); + + query(".composer-input").value = "hello"; + query(".composer-send").click(); + expect(calls.composerSends).toHaveLength(1); + expect(calls.composerSends[0]?.text).toBe("hello"); + shell.composerOk(calls.composerSends[0]?.req ?? ""); + expect(query(".composer-input").value).toBe(""); + + query(".composer-builder").click(); + expect(signal().builders).toBe(1); + query("#session-back").click(); + expect(signal().left).toBe(1); + }); + + it("mounts the terminal on ensureTerminal and writes to it", () => { + shell.showSession("s1", "build"); + expect(root.querySelector(".term-host")).toBeNull(); + shell.ensureTerminal("s1", 80, 24); + expect(root.querySelector(".term-host")).not.toBeNull(); + expect(handles).toHaveLength(1); + shell.writeTerminal("hi"); + expect(handles[0]?.written).toEqual(["hi"]); + // Same size: no remount. + shell.ensureTerminal("s1", 80, 24); + expect(handles).toHaveLength(1); + // Another session's snapshot: ignored. + shell.ensureTerminal("s2", 80, 24); + expect(handles).toHaveLength(1); + // Terminal input flows to the host. + handles[0]?.emitInput("x"); + expect(calls.inputs).toEqual(["x"]); + }); + + it("keeps the terminal and the composer draft across state changes", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + shell.writeTerminal("hi"); + query(".composer-input").value = "draft"; + shell.setConnectionState("retrying in 2s"); + expect(query(".topbar .conn").textContent).toBe("retrying in 2s"); + expect(handles).toHaveLength(1); + expect(handles[0]?.disposed).toBe(false); + expect(handles[0]?.written).toEqual(["hi"]); + expect(query(".composer-input").value).toBe("draft"); + }); + + it("shows the ↓ latest button while scrolled up", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + const latest = query("#term-latest"); + expect(latest.hidden).toBe(true); + handles[0]?.emitScroll(false); + expect(latest.hidden).toBe(false); + latest.click(); + expect(latest.hidden).toBe(true); + }); + + it("shows retry and pair-again on failure", () => { + shell.showSessions(); + shell.setConnectionState("failed: Too many devices connected"); + query("#conn-retry").click(); + expect(signal().retries).toBe(1); + shell.setConnectionState(`failed: ${connectFailMessage("unknown_device")}`); + query("#conn-pair-again").click(); + expect(signal().pairAgains).toBe(1); + }); + + it("shows the exit badge for the open session", () => { + shell.showSession("s1", "build"); + shell.showSessionExited(1); + const badge = query("#session-exit"); + expect(badge.textContent).toBe("exited (1)"); + expect((badge as HTMLElement).hidden).toBe(false); + }); + + it("zooms the terminal with the +/− buttons", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + // No crash without layout; zoom state lives in the view. + query("#term-zoom-in").click(); + query("#term-zoom-out").click(); + expect(handles).toHaveLength(1); + }); +}); diff --git a/apps/remote/web/test/composer.test.ts b/apps/remote/web/test/composer.test.ts new file mode 100644 index 0000000..7c29129 --- /dev/null +++ b/apps/remote/web/test/composer.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Composer } from "../src/composer.js"; + +describe("Composer", () => { + let slot: HTMLElement; + let composer: Composer; + let sent: { text: string; req: string }[]; + let builders: number; + + const query = (selector: string): T => { + const el = slot.querySelector(selector); + if (!el) { + throw new Error(`missing ${selector}`); + } + return el as T; + }; + + beforeEach(() => { + slot = document.createElement("div"); + document.body.appendChild(slot); + sent = []; + builders = 0; + composer = new Composer({ + onSend: (text, req) => sent.push({ text, req }), + onBuilder: () => { + builders += 1; + }, + }); + composer.mount(slot); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("sends the text with a req id", () => { + query(".composer-input").value = "hello\nworld"; + query(".composer-send").click(); + expect(sent).toHaveLength(1); + expect(sent[0]?.text).toBe("hello\nworld"); + expect(typeof sent[0]?.req).toBe("string"); + expect(composer.awaitingReply).toBe(true); + }); + + it("ignores empty submits", () => { + query(".composer-input").value = " \n "; + query(".composer-send").click(); + expect(sent).toHaveLength(0); + }); + + it("clears on the matching ok and ignores other reqs", () => { + query(".composer-input").value = "hello"; + query(".composer-send").click(); + const req = sent[0]?.req ?? ""; + expect(composer.handleOk("other")).toBe(false); + expect(query(".composer-input").value).toBe("hello"); + expect(composer.handleOk(req)).toBe(true); + expect(query(".composer-input").value).toBe(""); + expect(composer.awaitingReply).toBe(false); + }); + + it("keeps the text and shows the message on error", () => { + query(".composer-input").value = "hello"; + query(".composer-send").click(); + const req = sent[0]?.req ?? ""; + expect(composer.handleError("delivery_failed", "other")).toBe(false); + expect(composer.handleError("delivery_failed: host is gone", req)).toBe(true); + expect(query(".composer-input").value).toBe("hello"); + const error = query(".composer-error"); + expect(error.textContent).toBe("delivery_failed: host is gone"); + expect((error as HTMLElement).hidden).toBe(false); + // A retry clears the error. + query(".composer-send").click(); + expect((query(".composer-error") as HTMLElement).hidden).toBe(true); + }); + + it("accepts an unrouted error for the pending prompt", () => { + query(".composer-input").value = "hello"; + query(".composer-send").click(); + expect(composer.handleError("internal trouble", null)).toBe(true); + expect(query(".composer-error").textContent).toBe("internal trouble"); + }); + + it("opens the Builder", () => { + query(".composer-builder").click(); + expect(builders).toBe(1); + }); + + it("sends on Ctrl+Enter", () => { + query(".composer-input").value = "hi"; + query(".composer-input").dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }), + ); + expect(sent).toHaveLength(1); + }); +}); diff --git a/apps/remote/web/test/keys.test.ts b/apps/remote/web/test/keys.test.ts new file mode 100644 index 0000000..8d13e2c --- /dev/null +++ b/apps/remote/web/test/keys.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it } from "vitest"; +import { keyById, keyBytes, KEY_DEFS, renderKeyBar } from "../src/keys.js"; +import type { KeyDef } from "../src/keys.js"; + +describe("key bar bytes", () => { + const bytesOf = (id: string): number[] => { + const def = keyById(id); + if (!def) { + throw new Error(`missing key ${id}`); + } + return [...keyBytes(def)]; + }; + + it("sends the exact control bytes", () => { + expect(bytesOf("esc")).toEqual([0x1b]); + expect(bytesOf("tab")).toEqual([0x09]); + expect(bytesOf("up")).toEqual([0x1b, 0x5b, 0x41]); + expect(bytesOf("down")).toEqual([0x1b, 0x5b, 0x42]); + expect(bytesOf("left")).toEqual([0x1b, 0x5b, 0x44]); + expect(bytesOf("right")).toEqual([0x1b, 0x5b, 0x43]); + expect(bytesOf("enter")).toEqual([0x0d]); + expect(bytesOf("ctrl-c")).toEqual([0x03]); + expect(bytesOf("y")).toEqual([0x79]); + expect(bytesOf("n")).toEqual([0x6e]); + }); + + it("defines ten keys in bar order", () => { + expect(KEY_DEFS.map((def) => def.id)).toEqual([ + "esc", + "tab", + "up", + "down", + "left", + "right", + "enter", + "ctrl-c", + "y", + "n", + ]); + expect(keyById("nope")).toBeNull(); + }); +}); + +describe("renderKeyBar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders one button per key and reports presses", () => { + const bar = document.createElement("div"); + document.body.appendChild(bar); + const pressed: KeyDef[] = []; + renderKeyBar(bar, (def) => pressed.push(def)); + const buttons = [...bar.querySelectorAll("button.key")]; + expect(buttons).toHaveLength(10); + expect(buttons[0]?.textContent).toBe("Esc"); + buttons[7]?.click(); + expect(pressed.map((def) => def.id)).toEqual(["ctrl-c"]); + expect(bar.getAttribute("role")).toBe("toolbar"); + }); +}); diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts new file mode 100644 index 0000000..c0d05eb --- /dev/null +++ b/apps/remote/web/test/protocol.test.ts @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { b64encode } from "../src/crypto.js"; +import { + buildAttach, + buildDetach, + buildInput, + buildInputText, + buildList, + buildPrompt, + decodeSnapshotData, + decodeUtf8, + formatLastOutput, + newReqId, + parseError, + parseExited, + parseOk, + parseOut, + parseSessionAdded, + parseSessionItem, + parseSessionRemoved, + parseSessions, + statusDot, + workdirBasename, +} from "../src/protocol.js"; + +describe("builders", () => { + it("builds the exact session frames", () => { + expect(JSON.parse(buildList("r1"))).toEqual({ t: "list", req: "r1" }); + expect(JSON.parse(buildAttach("s1"))).toEqual({ t: "attach", id: "s1" }); + expect(JSON.parse(buildDetach("s1"))).toEqual({ t: "detach", id: "s1" }); + expect(JSON.parse(buildPrompt("r2", "s1", "hello"))).toEqual({ + t: "prompt", + req: "r2", + id: "s1", + text: "hello", + }); + }); + + it("base64-encodes input bytes", () => { + expect(JSON.parse(buildInput("s1", new Uint8Array([0x1b, 0x5b, 0x41])))).toEqual({ + t: "input", + id: "s1", + data: "G1tB", + }); + expect(JSON.parse(buildInputText("s1", "y"))).toEqual({ t: "input", id: "s1", data: "eQ==" }); + }); + + it("mints unique req ids", () => { + expect(newReqId()).not.toBe(newReqId()); + }); +}); + +describe("parseSessionItem", () => { + it("parses a full item with unix-seconds last output", () => { + expect( + parseSessionItem({ + id: "s1", + name: "build", + cli: "muse", + model: "spark", + workdir: "/home/u/proj", + status: "running", + last_output_at: 1793100000, + }), + ).toEqual({ + id: "s1", + name: "build", + cli: "muse", + model: "spark", + workdir: "/home/u/proj", + status: "running", + lastOutputAt: 1793100000_000, + }); + }); + + it("accepts millisecond and ISO timestamps", () => { + expect(parseSessionItem({ id: "a", last_output_at: 1793100000_000 })?.lastOutputAt).toBe( + 1793100000_000, + ); + expect( + parseSessionItem({ id: "a", last_output_at: "2026-10-27T00:00:00.000Z" })?.lastOutputAt, + ).toBe(Date.parse("2026-10-27T00:00:00.000Z")); + }); + + it("tolerates missing fields but never a missing id", () => { + expect(parseSessionItem({ id: "a" })).toEqual({ + id: "a", + name: "", + cli: "", + model: "", + workdir: "", + status: "", + lastOutputAt: null, + }); + expect(parseSessionItem({ name: "x" })).toBeNull(); + expect(parseSessionItem(null)).toBeNull(); + }); +}); + +describe("frame parsers", () => { + it("parses sessions and skips invalid items", () => { + expect(parseSessions({ t: "sessions", req: "r1", items: [{ id: "a" }, { nope: true }] })).toEqual([ + { id: "a", name: "", cli: "", model: "", workdir: "", status: "", lastOutputAt: null }, + ]); + expect(parseSessions({ t: "sessions" })).toBeNull(); + expect(parseSessions({ t: "out", id: "a", seq: 1, data: "eQ==" })).toBeNull(); + }); + + it("parses session_added, session_removed and exited", () => { + expect(parseSessionAdded({ t: "session_added", item: { id: "a" } })?.id).toBe("a"); + expect(parseSessionAdded({ t: "session_added" })).toBeNull(); + expect(parseSessionRemoved({ t: "session_removed", id: "a" })).toBe("a"); + expect(parseSessionRemoved({ t: "session_removed" })).toBeNull(); + expect(parseExited({ t: "exited", id: "a", code: 1 })).toEqual({ id: "a", code: 1 }); + expect(parseExited({ t: "exited", id: "a" })).toBeNull(); + }); + + it("parses out frames and rejects bad base64", () => { + expect(parseOut({ t: "out", id: "a", seq: 9, data: "eQ==" })).toEqual({ + id: "a", + seq: 9, + data: new Uint8Array([0x79]), + }); + expect(parseOut({ t: "out", id: "a", seq: 9, data: "!!!" })).toBeNull(); + expect(parseOut({ t: "out", id: "a", seq: 9 })).toBeNull(); + }); + + it("parses ok and error replies", () => { + expect(parseOk({ t: "ok", req: "r1" })).toBe("r1"); + expect(parseOk({ t: "ok" })).toBeNull(); + expect(parseError({ t: "error", req: "r1", code: "internal", message: "boom" })).toEqual({ + req: "r1", + code: "internal", + message: "boom", + }); + expect(parseError({ t: "error", code: "internal", message: "boom" })?.req).toBeNull(); + expect(parseError({ t: "error", code: "internal" })).toBeNull(); + }); + + it("decodes snapshot data as UTF-8", () => { + const bytes = decodeSnapshotData(b64encode(new TextEncoder().encode("héllo"))); + expect(bytes).not.toBeNull(); + expect(decodeUtf8(bytes ?? new Uint8Array())).toBe("héllo"); + expect(decodeSnapshotData("!!!")).toBeNull(); + }); +}); + +describe("display helpers", () => { + it("takes the workdir basename", () => { + expect(workdirBasename("/home/u/proj")).toBe("proj"); + expect(workdirBasename("/home/u/proj/")).toBe("proj"); + expect(workdirBasename("proj")).toBe("proj"); + expect(workdirBasename("")).toBe(""); + }); + + it("formats relative last-output times", () => { + const now = 1793100000_000; + expect(formatLastOutput(now, null)).toBe("no output yet"); + expect(formatLastOutput(now, now - 12_000)).toBe("last output 12s ago"); + expect(formatLastOutput(now, now - 3 * 60_000)).toBe("last output 3min ago"); + expect(formatLastOutput(now, now - 2 * 3600_000)).toBe("last output 2h ago"); + }); + + it("maps statuses to dots", () => { + expect(statusDot("running")).toBe("run"); + expect(statusDot("idle")).toBe("idle"); + expect(statusDot("exited")).toBe("dead"); + expect(statusDot("mystery")).toBe("unknown"); + }); +}); diff --git a/apps/remote/web/test/send.test.ts b/apps/remote/web/test/send.test.ts new file mode 100644 index 0000000..e1dc945 --- /dev/null +++ b/apps/remote/web/test/send.test.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { generateDeviceKey } from "../src/crypto.js"; +import { ConnectionManager } from "../src/reconnect.js"; +import { HostPeer } from "../src/peer.js"; +import type { PeerEvent } from "../src/peer.js"; +import { + connectRelay, + createPeerHarness, + createRelayHarness, + createTestHost, + createTurnFetcher, + createVisibilityHarness, + flush, + signTestAnswer, + waitForSend, +} from "./fakes.js"; + +const ANSWER_SDP = "v=0\r\no=- 9 9 IN IP4 127.0.0.1\r\n"; + +async function openPeer(): Promise<{ peer: HostPeer; sent: () => string[]; stop: () => void }> { + const relayHarness = createRelayHarness(); + const ws = await connectRelay(relayHarness); + const peerHarness = createPeerHarness(); + const turn = createTurnFetcher(); + const device = await generateDeviceKey(); + const host = await createTestHost(); + const peer = new HostPeer({ + relay: relayHarness.relay, + host: host.record, + device, + fetchTurn: turn.fetchTurn, + createConnection: peerHarness.factory, + nowMs: () => 1793100000_000, + }); + const pending = peer.connect(); + await waitForSend(ws); + const offer = ws.envelopes().find((e) => e.body.t === "offer"); + if (!offer) { + throw new Error("no offer sent"); + } + const body = offer.body as { sig: string }; + const sig = await signTestAnswer(host.privateKey, device.deviceId, body.sig, ANSWER_SDP); + ws.receive(`h:${host.record.host_id}`, { t: "answer", sdp: ANSWER_SDP, sig }); + for (let i = 0; i < 100 && !peerHarness.latest().remoteDescription; i += 1) { + await flush(); + } + peerHarness.latest().firstChannel().remoteOpen(); + await pending; + return { + peer, + sent: () => peerHarness.latest().firstChannel().sent, + stop: () => peer.close(), + }; +} + +describe("HostPeer.send", () => { + it("sends on the open data channel", async () => { + const { peer, sent, stop } = await openPeer(); + try { + expect(peer.send(`{"t":"list"}`)).toBe(true); + expect(sent()).toEqual([`{"t":"list"}`]); + } finally { + stop(); + } + }); + + it("refuses before open and after close", async () => { + const relayHarness = createRelayHarness(); + await connectRelay(relayHarness); + const device = await generateDeviceKey(); + const host = await createTestHost(); + const peer = new HostPeer({ + relay: relayHarness.relay, + host: host.record, + device, + fetchTurn: createTurnFetcher().fetchTurn, + createConnection: createPeerHarness().factory, + }); + expect(peer.send("x")).toBe(false); + peer.close(); + expect(peer.send("x")).toBe(false); + }); +}); + +class StubPeer { + readonly sent: string[] = []; + private readonly handlers = new Set<(ev: PeerEvent) => void>(); + + onEvent(handler: (ev: PeerEvent) => void): () => void { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + async connect(): Promise { + queueMicrotask(() => { + for (const handler of this.handlers) { + handler({ type: "open" }); + } + }); + } + + send(data: string): boolean { + this.sent.push(data); + return true; + } + + close(): void {} +} + +describe("ConnectionManager.send", () => { + it("forwards while open and refuses otherwise", async () => { + const relayHarness = createRelayHarness(); + const visibility = createVisibilityHarness(true); + const device = await generateDeviceKey(); + const host = await createTestHost(); + const stub = new StubPeer(); + const manager = new ConnectionManager({ + relay: relayHarness.relay, + host: host.record, + device, + fetchTurn: createTurnFetcher().fetchTurn, + createConnection: createPeerHarness().factory, + createPeer: () => stub as unknown as HostPeer, + visibility: visibility.visibility, + }); + try { + expect(manager.connected).toBe(false); + expect(manager.send("early")).toBe(false); + manager.start(); + relayHarness.latest().open(); + for (let i = 0; i < 100 && !manager.connected; i += 1) { + await flush(); + } + expect(manager.connected).toBe(true); + expect(manager.send(`{"t":"list"}`)).toBe(true); + expect(stub.sent).toEqual([`{"t":"list"}`]); + } finally { + manager.stop(); + } + }); +}); diff --git a/apps/remote/web/test/session.test.ts b/apps/remote/web/test/session.test.ts new file mode 100644 index 0000000..a419e7c --- /dev/null +++ b/apps/remote/web/test/session.test.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { createChannelHandler } from "../src/channel.js"; +import { b64encode } from "../src/crypto.js"; +import { buildList, parseError, parseExited, parseOk, parseOut, parseSessions } from "../src/protocol.js"; +import { SessionController } from "../src/session.js"; +import { SessionStore } from "../src/sessions.js"; +import { FakeDataChannel } from "./fakes.js"; + +const b64 = (text: string): string => b64encode(new TextEncoder().encode(text)); + +interface Harness { + channel: FakeDataChannel; + store: SessionStore; + controller: SessionController; + written: string[]; + cleared: number; + exited: { id: string; code: number }[]; + sent: () => unknown[]; + hostSend: (frame: unknown) => void; +} + +function setup(): Harness { + const channel = new FakeDataChannel("canopy-session-v1", { ordered: true }); + const store = new SessionStore(); + const written: string[] = []; + const exited: { id: string; code: number }[] = []; + let clears = 0; + const controller = new SessionController({ + send: (data) => { + channel.send(data); + return true; + }, + terminal: () => ({ + write: (text) => written.push(text), + clear: () => { + clears += 1; + }, + }), + onExited: (id, code) => exited.push({ id, code }), + }); + const frames = createChannelHandler({ + onSnapshot: (snapshot) => controller.handleSnapshot(snapshot), + onFrame: (frame) => { + const sessions = parseSessions(frame); + if (sessions) { + store.replace(sessions); + return; + } + const out = parseOut(frame); + if (out) { + controller.handleOut(out); + return; + } + const done = parseExited(frame); + if (done) { + store.markExited(done.id); + controller.handleExited(done.id, done.code); + return; + } + const ok = parseOk(frame); + if (ok !== null) { + return; + } + parseError(frame); + }, + }); + channel.onmessage = (ev: MessageEvent) => { + if (typeof ev.data === "string") { + frames.push(ev.data); + } + }; + return { + channel, + store, + controller, + written, + exited, + get cleared() { + return clears; + }, + sent: () => channel.sent.map((raw) => JSON.parse(raw) as unknown), + hostSend: (frame: unknown) => channel.deliver(JSON.stringify(frame)), + }; +} + +function snapshotFrames(id: string, seq: number, text: string): unknown[] { + // The host splits the base64 stream, so split the encoding, not the text. + const full = b64(text); + const cut = Math.min(3, full.length); + return [ + { t: "snapshot", id, cols: 80, rows: 24, seq, data: full.slice(0, cut) }, + { t: "snapshot_more", id, data: full.slice(cut), last: true }, + ]; +} + +describe("session flow over a fake data channel", () => { + it("lists, attaches, and feeds the terminal from snapshot plus out", () => { + const h = setup(); + h.channel.send(buildList("r1")); + h.hostSend({ + t: "sessions", + req: "r1", + items: [{ id: "s1", name: "build", cli: "muse", status: "running" }], + }); + expect(h.store.list().map((row) => row.id)).toEqual(["s1"]); + + h.controller.open("s1"); + expect(h.sent()).toContainEqual({ t: "attach", id: "s1" }); + for (const frame of snapshotFrames("s1", 7, "hello")) { + h.hostSend(frame); + } + expect(h.cleared).toBe(1); + expect(h.written.join("")).toBe("hello"); + + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("!") }); + expect(h.written.join("")).toBe("hello!"); + + h.controller.close(); + expect(h.sent()).toContainEqual({ t: "detach", id: "s1" }); + }); + + it("drops out frames at or below the last applied seq", () => { + const h = setup(); + h.controller.open("s1"); + for (const frame of snapshotFrames("s1", 7, "ab")) { + h.hostSend(frame); + } + h.hostSend({ t: "out", id: "s1", seq: 7, data: b64("STALE") }); + h.hostSend({ t: "out", id: "s1", seq: 6, data: b64("OLDER") }); + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("!") }); + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("DUP") }); + h.hostSend({ t: "out", id: "s2", seq: 99, data: b64("WRONG") }); + expect(h.written.join("")).toBe("ab!"); + }); + + it("holds early out frames until the snapshot, then applies them in order", () => { + const h = setup(); + h.controller.open("s1"); + h.hostSend({ t: "out", id: "s1", seq: 9, data: b64("c") }); + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("b") }); + expect(h.written).toEqual([]); + for (const frame of snapshotFrames("s1", 7, "a")) { + h.hostSend(frame); + } + expect(h.written.join("")).toBe("abc"); + }); + + it("re-attaches after a reconnect without duplicating output", () => { + const h = setup(); + h.controller.open("s1"); + for (const frame of snapshotFrames("s1", 7, "ab")) { + h.hostSend(frame); + } + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("c") }); + expect(h.written.join("")).toBe("abc"); + + // The reconnect: attach again, a fresh snapshot redraws, and the + // in-flight out frame from before the drop is dropped by seq. + expect(h.controller.reattach()).toBe(true); + expect(h.sent().filter((f) => (f as { t: string }).t === "attach")).toHaveLength(2); + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("c") }); + for (const frame of snapshotFrames("s1", 8, "abc")) { + h.hostSend(frame); + } + h.hostSend({ t: "out", id: "s1", seq: 9, data: b64("d") }); + expect(h.cleared).toBe(2); + expect(h.written.join("")).toBe("abcabcd"); + }); + + it("marks sessions exited and notifies the open one", () => { + const h = setup(); + h.hostSend({ t: "sessions", req: "r1", items: [{ id: "s1", status: "running" }] }); + h.controller.open("s1"); + h.hostSend({ t: "exited", id: "s1", code: 1 }); + expect(h.store.get("s1")?.status).toBe("exited"); + expect(h.exited).toEqual([{ id: "s1", code: 1 }]); + }); + + it("sends input and prompt frames for the open session", () => { + const h = setup(); + expect(h.controller.sendInputText("x")).toBe(false); + h.controller.open("s1"); + expect(h.controller.sendInputBytes(new Uint8Array([0x1b]))).toBe(true); + expect(h.controller.sendInputText("y")).toBe(true); + expect(h.controller.sendPrompt("do it", "r9")).toBe(true); + expect(h.sent()).toContainEqual({ t: "input", id: "s1", data: "Gw==" }); + expect(h.sent()).toContainEqual({ t: "input", id: "s1", data: "eQ==" }); + expect(h.sent()).toContainEqual({ t: "prompt", req: "r9", id: "s1", text: "do it" }); + }); + + it("detaches the previous session when opening another", () => { + const h = setup(); + h.controller.open("s1"); + h.controller.open("s2"); + expect(h.sent()).toContainEqual({ t: "detach", id: "s1" }); + expect(h.sent()).toContainEqual({ t: "attach", id: "s2" }); + }); +}); diff --git a/apps/remote/web/test/sessions.test.ts b/apps/remote/web/test/sessions.test.ts new file mode 100644 index 0000000..5668f6e --- /dev/null +++ b/apps/remote/web/test/sessions.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderSessions, SessionStore } from "../src/sessions.js"; +import type { SessionItem } from "../src/protocol.js"; + +const NOW = 1793100000_000; + +function item(overrides: Partial = {}): SessionItem { + return { + id: "s1", + name: "build", + cli: "muse", + model: "spark", + workdir: "/home/u/proj", + status: "running", + lastOutputAt: NOW - 12_000, + ...overrides, + }; +} + +describe("SessionStore", () => { + it("replaces, adds, removes and marks exited with notifications", () => { + const store = new SessionStore(); + const seen: number[] = []; + const stop = store.subscribe(() => seen.push(store.size)); + store.replace([item(), item({ id: "s2", name: "docs" })]); + expect(store.list().map((row) => row.id)).toEqual(["s1", "s2"]); + store.add(item({ id: "s3", name: "aaa" })); + expect(store.list().map((row) => row.id)).toEqual(["s3", "s1", "s2"]); + store.markExited("s1"); + expect(store.get("s1")?.status).toBe("exited"); + store.remove("s2"); + expect(store.get("s2")).toBeNull(); + expect(seen).toEqual([2, 3, 3, 2]); + stop(); + store.remove("s1"); + expect(seen).toEqual([2, 3, 3, 2]); + }); + + it("ignores removals and exits of unknown sessions", () => { + const store = new SessionStore(); + const notify = vi.fn(); + store.subscribe(notify); + store.remove("ghost"); + store.markExited("ghost"); + expect(notify).not.toHaveBeenCalled(); + }); +}); + +describe("renderSessions", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + function render(items: SessionItem[]) { + const root = document.createElement("div"); + document.body.appendChild(root); + const opened: string[] = []; + let news = 0; + renderSessions(root, items, NOW, { + onOpen: (id) => opened.push(id), + onNew: () => { + news += 1; + }, + }); + return { root, opened, news: () => news }; + } + + it("shows name, cli and model, workdir basename, dot and age", () => { + const { root, opened } = render([item()]); + const row = root.querySelector('[data-session="s1"]'); + expect(row?.querySelector(".session-name")?.textContent).toBe("build"); + expect(row?.querySelector(".session-sub")?.textContent).toBe("muse · spark · proj"); + expect(row?.querySelector(".session-age")?.textContent).toBe("last output 12s ago"); + expect(row?.querySelector(".dot-run")).not.toBeNull(); + row?.click(); + expect(opened).toEqual(["s1"]); + }); + + it("renders the dead dot for exited sessions", () => { + const { root } = render([item({ status: "exited" })]); + expect(root.querySelector(".dot-dead")).not.toBeNull(); + }); + + it("shows the New button and the empty state", () => { + const empty = render([]); + expect(empty.root.querySelector(".empty")?.textContent).toContain("No sessions"); + empty.root.querySelector("#session-new")?.click(); + expect(empty.news()).toBe(1); + }); + + it("escapes session names", () => { + const { root } = render([item({ name: "" })]); + expect(root.querySelector(".session-name")?.innerHTML).toBe("<img src=x>"); + }); +}); diff --git a/apps/remote/web/test/smoke.spec.ts b/apps/remote/web/test/smoke.spec.ts new file mode 100644 index 0000000..03febc4 --- /dev/null +++ b/apps/remote/web/test/smoke.spec.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/fake.html"); + await expect(page.locator('[data-session="smoke"]')).toBeVisible(); + await page.locator('[data-session="smoke"]').click(); + await expect(page.locator(".xterm-rows")).toContainText("hello from the host"); +}); + +test("renders the terminal and the key bar at 360x640", async ({ page }) => { + await expect(page.locator(".xterm-rows")).toContainText("$ muse run"); + const keys = page.locator("#keybar .key"); + await expect(keys).toHaveCount(10); + await expect(page.locator(".topbar .login")).toHaveText("smoke"); + await expect(page.locator(".topbar .host")).toHaveText("smoke host"); + // Live output flows into the terminal. + await page.evaluate(() => window.__fake.pushOut("live line\n")); + await expect(page.locator(".xterm-rows")).toContainText("live line"); + // No horizontal page scroll outside the terminal pane. + const overflow = await page.evaluate(() => { + const el = document.scrollingElement ?? document.documentElement; + return el.scrollWidth - el.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test("sends key bar bytes as input", async ({ page }) => { + await page.evaluate(() => window.__fake.clearSent()); + await page.locator('#keybar .key[data-key="y"]').click(); + await page.locator('#keybar .key[data-key="up"]').click(); + const sent = await page.evaluate(() => window.__fake.sent()); + expect(sent).toContainEqual({ t: "input", id: "smoke", data: "eQ==" }); + expect(sent).toContainEqual({ t: "input", id: "smoke", data: "G1tB" }); +}); + +test("composer sends a prompt and clears on ok", async ({ page }) => { + await page.locator(".composer-input").fill("do the thing"); + await page.locator(".composer-send").click(); + const sent = await page.evaluate(() => window.__fake.sent()); + const prompt = (sent as { t: string }[]).find((frame) => frame.t === "prompt"); + expect(prompt).toMatchObject({ t: "prompt", id: "smoke", text: "do the thing" }); + await expect(page.locator(".composer-input")).toHaveValue(""); +}); + +test("composer keeps the text and shows the error", async ({ page }) => { + await page.evaluate(() => window.__fake.failNextPrompt("delivery_failed", "host is gone")); + await page.locator(".composer-input").fill("do the thing"); + await page.locator(".composer-send").click(); + await expect(page.locator(".composer-error")).toHaveText("host is gone"); + await expect(page.locator(".composer-input")).toHaveValue("do the thing"); +}); diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts new file mode 100644 index 0000000..ca41ea6 --- /dev/null +++ b/apps/remote/web/test/terminal-view.test.ts @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it } from "vitest"; +import { + clampZoom, + computeScale, + MAX_ZOOM, + MIN_ZOOM, + TerminalView, + zoomFromPinch, +} from "../src/terminal.js"; +import type { TerminalFactory, TerminalHandle } from "../src/terminal.js"; + +class FakeHandle implements TerminalHandle { + written: string[] = []; + clears = 0; + focused = 0; + disposed = false; + atBottom = true; + scrolledToBottom = 0; + cols: number; + rows: number; + private inputListeners: ((text: string) => void)[] = []; + private scrollListeners: (() => void)[] = []; + + constructor(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + } + + write = (text: string): void => { + this.written.push(text); + }; + clear = (): void => { + this.clears += 1; + }; + focus = (): void => { + this.focused += 1; + }; + scrollToBottom = (): void => { + this.scrolledToBottom += 1; + this.atBottom = true; + }; + isAtBottom = (): boolean => this.atBottom; + onUserInput = (callback: (text: string) => void): (() => void) => { + this.inputListeners.push(callback); + return () => {}; + }; + onScroll = (callback: () => void): (() => void) => { + this.scrollListeners.push(callback); + return () => {}; + }; + dispose = (): void => { + this.disposed = true; + }; + + emitInput(text: string): void { + for (const listener of this.inputListeners) { + listener(text); + } + } + + emitScroll(atBottom: boolean): void { + this.atBottom = atBottom; + for (const listener of this.scrollListeners) { + listener(); + } + } +} + +function setup() { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const handles: FakeHandle[] = []; + const factory: TerminalFactory = (_host, cols, rows) => { + const handle = new FakeHandle(cols, rows); + handles.push(handle); + return handle; + }; + const inputs: string[] = []; + const follows: boolean[] = []; + const view = new TerminalView(factory, { + onUserInput: (text) => inputs.push(text), + onFollowChange: ({ following }) => follows.push(following), + }); + return { parent, view, handles, inputs, follows }; +} + +describe("scale math", () => { + it("fits the full width times the zoom", () => { + expect(computeScale(360, 720, 1)).toBe(0.5); + expect(computeScale(360, 720, 2)).toBe(1); + expect(computeScale(0, 720, 1)).toBe(1); + expect(computeScale(360, 0, 1)).toBe(1); + }); + + it("clamps the zoom", () => { + expect(clampZoom(99)).toBe(MAX_ZOOM); + expect(clampZoom(0)).toBe(MIN_ZOOM); + expect(clampZoom(1.5)).toBe(1.5); + }); + + it("derives zoom from a pinch", () => { + expect(zoomFromPinch(100, 200, 1)).toBe(2); + expect(zoomFromPinch(200, 100, 1)).toBe(0.5); + expect(zoomFromPinch(0, 100, 1)).toBe(1); + }); +}); + +describe("TerminalView", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("mounts with the snapshot size and forwards input", () => { + const { parent, view, handles, inputs } = setup(); + view.mount(parent, 80, 24); + expect(view.fitted).toBe(true); + expect(handles).toHaveLength(1); + expect(handles[0]?.cols).toBe(80); + expect(handles[0]?.rows).toBe(24); + view.write("hi"); + expect(handles[0]?.written).toEqual(["hi"]); + handles[0]?.emitInput("x"); + expect(inputs).toEqual(["x"]); + parent.querySelector(".term-host")?.dispatchEvent(new Event("click", { bubbles: true })); + // Clicking the terminal focuses it, opening the soft keyboard. + expect(handles[0]?.focused).toBe(1); + view.dispose(); + expect(handles[0]?.disposed).toBe(true); + expect(view.fitted).toBe(false); + }); + + it("tracks follow state and scrolls to latest", () => { + const { parent, view, handles, follows } = setup(); + view.mount(parent, 80, 24); + expect(view.isFollowing).toBe(true); + handles[0]?.emitScroll(false); + expect(view.isFollowing).toBe(false); + expect(follows).toEqual([false]); + view.scrollToLatest(); + expect(handles[0]?.scrolledToBottom).toBe(1); + expect(view.isFollowing).toBe(true); + expect(follows).toEqual([false, true]); + view.dispose(); + }); + + it("zooms in steps within bounds", () => { + const { parent, view } = setup(); + view.mount(parent, 80, 24); + expect(view.currentZoom).toBe(1); + view.zoomIn(); + expect(view.currentZoom).toBeCloseTo(1.25, 5); + view.zoomOut(); + view.zoomOut(); + expect(view.currentZoom).toBeCloseTo(0.8, 5); + view.setZoom(99); + expect(view.currentZoom).toBe(MAX_ZOOM); + view.dispose(); + }); + + it("builds the fit > scale > host scaffold", () => { + const { parent, view } = setup(); + view.mount(parent, 80, 24); + const fit = parent.querySelector(".term-fit"); + expect(fit?.querySelector(".term-scale > .term-host")).not.toBeNull(); + view.dispose(); + expect(parent.querySelector(".term-fit")).toBeNull(); + }); +}); diff --git a/package-lock.json b/package-lock.json index a198143..1f943f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "@xterm/xterm": "5.5.0" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.12.21", "@cloudflare/workers-types": "^5.20261026.1", "@eslint/js": "^9.18.0", + "@playwright/test": "1.64.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", "fake-indexeddb": "6.2.5", @@ -2064,6 +2066,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -3224,6 +3242,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -4618,6 +4642,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", diff --git a/package.json b/package.json index c0d531e..4f75872 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,18 @@ "test:remote-web": "vitest run --config apps/remote/web/vitest.config.ts", "test:worker": "vitest run --config apps/graphs/vitest.config.ts", "test:remote-worker": "vitest run --config apps/remote/worker/vitest.config.ts", - "validate": "tsx src/validate.ts" + "validate": "tsx src/validate.ts", + "test:remote-smoke": "playwright test --config apps/remote/web/playwright.config.ts" }, "dependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "@xterm/xterm": "5.5.0" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.12.21", "@cloudflare/workers-types": "^5.20261026.1", "@eslint/js": "^9.18.0", + "@playwright/test": "1.64.0", "@types/node": "^22.10.0", "eslint": "^9.18.0", "fake-indexeddb": "6.2.5", From 2686681b5e9ff6814f2e84888bdb0d13dc7ed60e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 21:26:05 -0500 Subject: [PATCH 018/180] feat(sessions): show the host's sessions with a live terminal, a key bar and a prompt composer (reviewer 2 fixes) --- apps/remote/web/src/app.ts | 6 ++ apps/remote/web/src/fake-host.ts | 30 ++---- apps/remote/web/src/main.ts | 69 ++------------ apps/remote/web/src/router.ts | 82 ++++++++++++++++ apps/remote/web/src/styles.css | 8 +- apps/remote/web/test/app.test.ts | 19 ++++ apps/remote/web/test/router.test.ts | 140 ++++++++++++++++++++++++++++ apps/remote/web/test/smoke.spec.ts | 15 +++ 8 files changed, 283 insertions(+), 86 deletions(-) create mode 100644 apps/remote/web/src/router.ts create mode 100644 apps/remote/web/test/router.test.ts diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts index 9069ea1..f3a6d20 100644 --- a/apps/remote/web/src/app.ts +++ b/apps/remote/web/src/app.ts @@ -337,10 +337,14 @@ export function createShell( return { showSignIn(): void { + teardownSessionWidgets(); + sessionId = null; route = "signin"; render(); }, showPair(prefillCode?: string): void { + teardownSessionWidgets(); + sessionId = null; route = "pair"; pairError = null; pairBusy = false; @@ -352,6 +356,8 @@ export function createShell( render(); }, showHosts(): void { + teardownSessionWidgets(); + sessionId = null; route = "hosts"; render(); }, diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts index 83e729f..25e8d06 100644 --- a/apps/remote/web/src/fake-host.ts +++ b/apps/remote/web/src/fake-host.ts @@ -6,7 +6,8 @@ import { createShell } from "./app.js"; import { createChannelHandler } from "./channel.js"; import { b64encode } from "./crypto.js"; -import { buildList, newReqId, parseOut, parseSessions } from "./protocol.js"; +import { buildList, newReqId } from "./protocol.js"; +import { createFrameRouter } from "./router.js"; import { SessionController } from "./session.js"; import { SessionStore } from "./sessions.js"; import "./styles.css"; @@ -90,31 +91,14 @@ function main(): void { write: (text) => shell.writeTerminal(text), clear: () => shell.clearTerminal(), }), + onExited: (_id, code) => shell.showSessionExited(code), }); + // The smoke test drives the same routing the app uses. + const router = createFrameRouter({ store, controller, shell }); const frames = createChannelHandler({ - onSnapshot: (snapshot) => { - shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); - controller.handleSnapshot(snapshot); - }, - onFrame: (frame) => { - const sessions = parseSessions(frame); - if (sessions) { - store.replace(sessions); - return; - } - const out = parseOut(frame); - if (out) { - controller.handleOut(out); - return; - } - const row = frame as { t?: string; req?: string; code?: string; message?: string }; - if (row.t === "ok" && typeof row.req === "string") { - shell.composerOk(row.req); - } else if (row.t === "error" && typeof row.message === "string") { - shell.composerError(row.message, typeof row.req === "string" ? row.req : null); - } - }, + onSnapshot: router.handleSnapshot, + onFrame: router.handleFrame, }); shell.setLogin("smoke"); diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts index 75db9ac..a815c28 100644 --- a/apps/remote/web/src/main.ts +++ b/apps/remote/web/src/main.ts @@ -8,24 +8,14 @@ import { createShell } from "./app.js"; import type { Shell } from "./app.js"; import { createChannelHandler } from "./channel.js"; -import type { JoinedSnapshot } from "./channel.js"; import { generateDeviceKey } from "./crypto.js"; import type { DeviceKey } from "./crypto.js"; import { keyBytes } from "./keys.js"; -import { - buildList, - newReqId, - parseError, - parseExited, - parseOk, - parseOut, - parseSessionAdded, - parseSessionRemoved, - parseSessions, -} from "./protocol.js"; +import { buildList, newReqId } from "./protocol.js"; import { runPairing } from "./pairing.js"; import { ConnectionManager } from "./reconnect.js"; import { RelaySocket, defaultRelayUrl, parseHostsList } from "./relay.js"; +import { createFrameRouter } from "./router.js"; import type { OnlineHost } from "./relay.js"; import { SessionController } from "./session.js"; import { SessionStore } from "./sessions.js"; @@ -140,59 +130,17 @@ async function main(): Promise { shell.setPairedHosts(await listHosts()); }; - function handleSnapshot(snapshot: JoinedSnapshot): void { - globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); - shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); - controller.handleSnapshot(snapshot); - } - - function handleFrame(frame: unknown): void { - globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); - const sessions = parseSessions(frame); - if (sessions) { - store.replace(sessions); - return; - } - const added = parseSessionAdded(frame); - if (added) { - store.add(added); - return; - } - const removed = parseSessionRemoved(frame); - if (removed !== null) { - store.remove(removed); - return; - } - const exited = parseExited(frame); - if (exited) { - store.markExited(exited.id); - controller.handleExited(exited.id, exited.code); - return; - } - const out = parseOut(frame); - if (out) { - controller.handleOut(out); - return; - } - const ok = parseOk(frame); - if (ok !== null) { - shell.composerOk(ok); - return; - } - const error = parseError(frame); - if (error) { - shell.composerError(error.message, error.req); - } - } + const router = createFrameRouter({ store, controller, shell }); function startSession(host: HostRecord): void { - manager?.stop(); + // Detach while the old connection is still open so the host sees it. controller.close(); + manager?.stop(); shell.setHostLabel(host.label); shell.showSessions(); const frames = createChannelHandler({ - onSnapshot: handleSnapshot, - onFrame: handleFrame, + onSnapshot: router.handleSnapshot, + onFrame: router.handleFrame, }); const next = new ConnectionManager({ relay, host, device }); manager = next; @@ -254,9 +202,10 @@ async function main(): Promise { } async function doDisconnect(): Promise { + // Detach while the connection is still open so the host sees it. + controller.close(); manager?.stop(); manager = null; - controller.close(); const stored = await listHosts(); await refreshPaired(); if (stored.length === 0) { diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts new file mode 100644 index 0000000..a3e68e1 --- /dev/null +++ b/apps/remote/web/src/router.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Frame routing between the data channel and the session shell: joined +// snapshots mount and feed the terminal, every other protocol frame goes +// to the session store, the open session, or the composer. Extracted from +// the entry point so tests pin the exact wiring the app uses; main.ts and +// the fake host both route through here. + +import type { Shell } from "./app.js"; +import type { JoinedSnapshot } from "./channel.js"; +import { + parseError, + parseExited, + parseOk, + parseOut, + parseSessionAdded, + parseSessionRemoved, + parseSessions, +} from "./protocol.js"; +import type { SessionController } from "./session.js"; +import type { SessionStore } from "./sessions.js"; + +export interface FrameRouter { + handleSnapshot: (snapshot: JoinedSnapshot) => void; + handleFrame: (frame: unknown) => void; +} + +export interface FrameRouterDeps { + store: SessionStore; + controller: SessionController; + shell: Shell; +} + +export function createFrameRouter(deps: FrameRouterDeps): FrameRouter { + const { store, controller, shell } = deps; + + function handleSnapshot(snapshot: JoinedSnapshot): void { + globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); + shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); + controller.handleSnapshot(snapshot); + } + + function handleFrame(frame: unknown): void { + globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); + const sessions = parseSessions(frame); + if (sessions) { + store.replace(sessions); + return; + } + const added = parseSessionAdded(frame); + if (added) { + store.add(added); + return; + } + const removed = parseSessionRemoved(frame); + if (removed !== null) { + store.remove(removed); + return; + } + const exited = parseExited(frame); + if (exited) { + store.markExited(exited.id); + controller.handleExited(exited.id, exited.code); + return; + } + const out = parseOut(frame); + if (out) { + controller.handleOut(out); + return; + } + const ok = parseOk(frame); + if (ok !== null) { + shell.composerOk(ok); + return; + } + const error = parseError(frame); + if (error) { + shell.composerError(error.message, error.req); + } + } + + return { handleSnapshot, handleFrame }; +} diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index 0b4e69e..8418b2f 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -315,9 +315,11 @@ label { .term-fit { width: 100%; height: 100%; - overflow: hidden; - /* Single-finger pans the xterm viewport natively; pinch reaches the - * zoom handler instead of the browser. */ + /* Auto so the zoomed terminal pans inside the pane; at zoom 1 the + * scaled content fits exactly and no scrollbar appears. Single-finger + * pans the xterm viewport natively; pinch reaches the zoom handler + * instead of the browser. */ + overflow: auto; touch-action: pan-x pan-y; } diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index 849922d..0bcf305 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -299,6 +299,25 @@ describe("createShell", () => { expect(query(".composer-input").value).toBe("draft"); }); + it("disposes the terminal and composer when leaving the session route", () => { + const leaves: (() => void)[] = [ + () => shell.showPair(), + () => shell.showHosts(), + () => shell.showSignIn(), + () => shell.showSessions(), + ]; + for (const leave of leaves) { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + expect(root.querySelector(".composer")).not.toBeNull(); + const handle = handles[handles.length - 1]; + leave(); + expect(handle?.disposed).toBe(true); + expect(root.querySelector(".term-host")).toBeNull(); + expect(root.querySelector(".composer")).toBeNull(); + } + }); + it("shows the ↓ latest button while scrolled up", () => { shell.showSession("s1", "build"); shell.ensureTerminal("s1", 80, 24); diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts new file mode 100644 index 0000000..a3bbaa0 --- /dev/null +++ b/apps/remote/web/test/router.test.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// The production frame wiring, driven through a fake data channel exactly +// as main.ts chains it: channel text → snapshot assembler → router. + +import { describe, expect, it } from "vitest"; +import type { Shell } from "../src/app.js"; +import { createChannelHandler } from "../src/channel.js"; +import { b64encode } from "../src/crypto.js"; +import { createFrameRouter } from "../src/router.js"; +import type { FrameRouter } from "../src/router.js"; +import { SessionController } from "../src/session.js"; +import { SessionStore } from "../src/sessions.js"; +import { FakeDataChannel } from "./fakes.js"; + +const b64 = (text: string): string => b64encode(new TextEncoder().encode(text)); + +interface Harness { + router: FrameRouter; + store: SessionStore; + controller: SessionController; + written: string[]; + terminals: { id: string; cols: number; rows: number }[]; + oks: string[]; + errors: { message: string; req: string | null }[]; + exits: number[]; + hostSend: (frame: unknown) => void; +} + +function setup(): Harness { + const channel = new FakeDataChannel("canopy-session-v1", { ordered: true }); + const store = new SessionStore(); + const written: string[] = []; + const terminals: Harness["terminals"] = []; + const oks: string[] = []; + const errors: Harness["errors"] = []; + const exits: number[] = []; + // Only the methods the router calls; main.ts wires the real shell. + const shell = { + ensureTerminal: (id: string, cols: number, rows: number) => { + terminals.push({ id, cols, rows }); + }, + composerOk: (req: string) => { + oks.push(req); + }, + composerError: (message: string, req: string | null) => { + errors.push({ message, req }); + }, + showSessionExited: (code: number) => { + exits.push(code); + }, + } as unknown as Shell; + const controller = new SessionController({ + send: (data) => { + channel.send(data); + return true; + }, + terminal: () => ({ + write: (text: string) => written.push(text), + clear: () => {}, + }), + onExited: (_id, code) => shell.showSessionExited(code), + }); + const router = createFrameRouter({ store, controller, shell }); + const frames = createChannelHandler({ + onSnapshot: router.handleSnapshot, + onFrame: router.handleFrame, + }); + channel.onmessage = (ev: MessageEvent) => { + if (typeof ev.data === "string") { + frames.push(ev.data); + } + }; + return { + router, + store, + controller, + written, + terminals, + oks, + errors, + exits, + hostSend: (frame: unknown) => channel.deliver(JSON.stringify(frame)), + }; +} + +describe("createFrameRouter", () => { + it("routes the session list and its updates to the store", () => { + const h = setup(); + h.hostSend({ t: "sessions", req: "r1", items: [{ id: "s1", name: "build", status: "running" }] }); + expect(h.store.list().map((row) => row.id)).toEqual(["s1"]); + h.hostSend({ t: "session_added", item: { id: "s2", name: "docs", status: "idle" } }); + expect(h.store.list().map((row) => row.id)).toEqual(["s1", "s2"].sort()); + h.hostSend({ t: "session_removed", id: "s1" }); + expect(h.store.get("s1")).toBeNull(); + expect(h.store.get("s2")?.name).toBe("docs"); + }); + + it("marks the session exited in the store and on the open view", () => { + const h = setup(); + h.hostSend({ t: "sessions", req: "r1", items: [{ id: "s1", status: "running" }] }); + h.controller.open("s1"); + h.hostSend({ t: "exited", id: "s1", code: 1 }); + expect(h.store.get("s1")?.status).toBe("exited"); + expect(h.exits).toEqual([1]); + }); + + it("mounts the terminal from the snapshot before feeding it", () => { + const h = setup(); + h.controller.open("s1"); + const full = b64("hello"); + h.hostSend({ t: "snapshot", id: "s1", cols: 80, rows: 24, seq: 7, data: full.slice(0, 3) }); + expect(h.terminals).toEqual([]); + expect(h.written).toEqual([]); + h.hostSend({ t: "snapshot_more", id: "s1", data: full.slice(3), last: true }); + expect(h.terminals).toEqual([{ id: "s1", cols: 80, rows: 24 }]); + expect(h.written.join("")).toBe("hello"); + h.hostSend({ t: "out", id: "s1", seq: 8, data: b64("!") }); + expect(h.written.join("")).toBe("hello!"); + }); + + it("routes ok and error replies to the composer", () => { + const h = setup(); + h.hostSend({ t: "ok", req: "r9" }); + expect(h.oks).toEqual(["r9"]); + h.hostSend({ t: "error", req: "r10", code: "delivery_failed", message: "host is gone" }); + expect(h.errors).toEqual([{ message: "host is gone", req: "r10" }]); + }); + + it("ignores unknown frames without throwing", () => { + const h = setup(); + expect(() => { + h.hostSend({ t: "mystery", id: "s1" }); + h.hostSend({ nope: true }); + h.router.handleFrame(null); + }).not.toThrow(); + expect(h.store.size).toBe(0); + expect(h.oks).toEqual([]); + expect(h.errors).toEqual([]); + }); +}); diff --git a/apps/remote/web/test/smoke.spec.ts b/apps/remote/web/test/smoke.spec.ts index 03febc4..2541860 100644 --- a/apps/remote/web/test/smoke.spec.ts +++ b/apps/remote/web/test/smoke.spec.ts @@ -25,6 +25,21 @@ test("renders the terminal and the key bar at 360x640", async ({ page }) => { expect(overflow).toBeLessThanOrEqual(1); }); +test("terminal pans when zoomed", async ({ page }) => { + await page.locator("#term-zoom-in").click(); + await page.locator("#term-zoom-in").click(); + const pan = await page.evaluate(() => { + const fit = document.querySelector(".term-fit") as HTMLElement; + return { + overflow: getComputedStyle(fit).overflowX, + larger: fit.scrollWidth > fit.clientWidth, + }; + }); + // Zoomed content exceeds the pane, and the pane lets the user pan it. + expect(pan.larger).toBe(true); + expect(["auto", "scroll"]).toContain(pan.overflow); +}); + test("sends key bar bytes as input", async ({ page }) => { await page.evaluate(() => window.__fake.clearSent()); await page.locator('#keybar .key[data-key="y"]').click(); From 61d3d45cfe61d3f9dcb331b6a78e809b2ef61ba4 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 21:46:56 -0500 Subject: [PATCH 019/180] feat(sessions): start new agent sessions and build prompts from the browser --- apps/remote/web/src/app.ts | 82 ++++- apps/remote/web/src/builder.ts | 451 ++++++++++++++++++++++++ apps/remote/web/src/catalog.ts | 78 ++++ apps/remote/web/src/draft.ts | 236 +++++++++++++ apps/remote/web/src/fake-host.ts | 173 ++++++--- apps/remote/web/src/main.ts | 28 +- apps/remote/web/src/new-dialog.ts | 341 ++++++++++++++++++ apps/remote/web/src/protocol.ts | 173 +++++++++ apps/remote/web/src/router.ts | 32 +- apps/remote/web/src/session.ts | 12 +- apps/remote/web/src/sheet.ts | 83 +++++ apps/remote/web/src/styles.css | 178 ++++++++++ apps/remote/web/test/app.test.ts | 136 ++++++- apps/remote/web/test/builder.test.ts | 316 +++++++++++++++++ apps/remote/web/test/catalog.test.ts | 61 ++++ apps/remote/web/test/draft.test.ts | 199 +++++++++++ apps/remote/web/test/fakes.ts | 41 +++ apps/remote/web/test/new-dialog.test.ts | 180 ++++++++++ apps/remote/web/test/protocol.test.ts | 94 +++++ apps/remote/web/test/router.test.ts | 60 +++- apps/remote/web/test/send-draft.test.ts | 158 +++++++++ 21 files changed, 3034 insertions(+), 78 deletions(-) create mode 100644 apps/remote/web/src/builder.ts create mode 100644 apps/remote/web/src/catalog.ts create mode 100644 apps/remote/web/src/draft.ts create mode 100644 apps/remote/web/src/new-dialog.ts create mode 100644 apps/remote/web/src/sheet.ts create mode 100644 apps/remote/web/test/builder.test.ts create mode 100644 apps/remote/web/test/catalog.test.ts create mode 100644 apps/remote/web/test/draft.test.ts create mode 100644 apps/remote/web/test/new-dialog.test.ts create mode 100644 apps/remote/web/test/send-draft.test.ts diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts index f3a6d20..4d51447 100644 --- a/apps/remote/web/src/app.ts +++ b/apps/remote/web/src/app.ts @@ -6,10 +6,14 @@ // appears only when several hosts are paired. import { Composer } from "./composer.js"; +import { CatalogStore } from "./catalog.js"; +import { Builder } from "./builder.js"; import { renderKeyBar } from "./keys.js"; import type { KeyDef } from "./keys.js"; +import { NewDialog } from "./new-dialog.js"; import { defaultDeviceLabel } from "./pairing.js"; import { connectFailMessage } from "./peer.js"; +import type { PromptDraft, SpawnRequest } from "./protocol.js"; import type { OnlineHost } from "./relay.js"; import { renderSessions } from "./sessions.js"; import type { SessionStore } from "./sessions.js"; @@ -28,8 +32,10 @@ export interface ShellCallbacks { onKeyPress: (def: KeyDef) => void; onTerminalInput: (text: string) => void; onComposerSend: (text: string, req: string) => void; - onNew: () => void; - onBuilder: () => void; + /** False when the frame could not be sent; the dialog then says so. */ + onSpawn: (spawn: SpawnRequest, req: string) => boolean; + onCompose: (draft: PromptDraft, req: string) => boolean; + onSendDraft: (draft: PromptDraft, req: string) => boolean; } export interface Shell { @@ -38,6 +44,8 @@ export interface Shell { showHosts: () => void; showSessions: () => void; showSession: (id: string, name: string) => void; + showNew: () => void; + showBuilder: () => void; setLogin: (login: string | null) => void; setHostLabel: (label: string) => void; setConnectionState: (state: string) => void; @@ -51,11 +59,16 @@ export interface Shell { showSessionExited: (code: number) => void; composerOk: (req: string) => void; composerError: (message: string, req: string | null) => void; + sheetSpawned: (req: string, id: string) => void; + sheetComposed: (req: string, text: string) => void; + sheetOk: (req: string) => void; + sheetError: (message: string, req: string | null, code: string) => void; dispose: () => void; } export interface ShellDeps { terminalFactory?: TerminalFactory; + catalog?: CatalogStore; } type Route = "signin" | "pair" | "hosts" | "sessions" | "session"; @@ -92,6 +105,21 @@ export function createShell( let composer: Composer | null = null; let latestButton: HTMLButtonElement | null = null; + // The catalog the host sent and the two sheets that consume it. The + // dialogs subscribe to the store themselves while they are open. + const catalog = deps?.catalog ?? new CatalogStore(); + const newDialog = new NewDialog(catalog, { + onSpawn: (spawn, req) => callbacks.onSpawn(spawn, req), + }); + const builder = new Builder(catalog, { + onCompose: (draft, req) => callbacks.onCompose(draft, req), + onSend: (draft, req) => callbacks.onSendDraft(draft, req), + }); + const closeSheets = () => { + newDialog.close(); + builder.close(); + }; + const snapshotDraft = () => { const code = root.querySelector("#pair-code"); const host = root.querySelector("#pair-host"); @@ -241,10 +269,26 @@ export function createShell( const renderSessionsList = (slot: HTMLElement) => { renderSessions(slot, store.list(), Date.now(), { onOpen: (id) => callbacks.onOpenSession(id), - onNew: () => callbacks.onNew(), + onNew: () => openNew(), }); }; + const openNew = () => { + if (route !== "sessions") { + return; + } + builder.close(); + newDialog.open(root); + }; + + const openBuilder = () => { + if (route !== "session") { + return; + } + newDialog.close(); + builder.open(root); + }; + const teardownSessionWidgets = () => { terminalView?.dispose(); terminalView = null; @@ -296,7 +340,7 @@ export function createShell( if (slot) { composer = new Composer({ onSend: (text, req) => callbacks.onComposerSend(text, req), - onBuilder: () => callbacks.onBuilder(), + onBuilder: () => openBuilder(), }); composer.mount(slot); } @@ -337,12 +381,14 @@ export function createShell( return { showSignIn(): void { + closeSheets(); teardownSessionWidgets(); sessionId = null; route = "signin"; render(); }, showPair(prefillCode?: string): void { + closeSheets(); teardownSessionWidgets(); sessionId = null; route = "pair"; @@ -356,23 +402,32 @@ export function createShell( render(); }, showHosts(): void { + closeSheets(); teardownSessionWidgets(); sessionId = null; route = "hosts"; render(); }, showSessions(): void { + closeSheets(); teardownSessionWidgets(); sessionId = null; route = "sessions"; render(); }, showSession(id: string, name: string): void { + closeSheets(); sessionId = id; sessionName = name; route = "session"; render(); }, + showNew(): void { + openNew(); + }, + showBuilder(): void { + openBuilder(); + }, setLogin(next: string | null): void { login = next; if (route !== "signin") { @@ -479,9 +534,28 @@ export function createShell( composerError(message: string, req: string | null): void { composer?.handleError(message, req); }, + sheetSpawned(req: string, id: string): void { + if (!newDialog.handleSpawned(req)) { + return; + } + closeSheets(); + callbacks.onOpenSession(id); + }, + sheetComposed(req: string, text: string): void { + builder.handleComposed(req, text); + }, + sheetOk(req: string): void { + builder.handleOk(req); + }, + sheetError(message: string, req: string | null, code: string): void { + if (!builder.handleError(message, req, code)) { + newDialog.handleError(message, req, code); + } + }, dispose(): void { globalThis.clearInterval(ticker); stopStore(); + closeSheets(); teardownSessionWidgets(); composer?.unmount(); composer = null; diff --git a/apps/remote/web/src/builder.ts b/apps/remote/web/src/builder.ts new file mode 100644 index 0000000..307b53f --- /dev/null +++ b/apps/remote/web/src/builder.ts @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: Apache-2.0 +// The Prompt Builder sheet, opened from the session composer. Ordered +// sections with add / remove / move, an optional preset with its preview, +// skills, and an optional project — all from the host catalog. Preview +// asks the host to compose (compose/composed) and shows the text +// read-only; Send ships prompt{id,draft} to the open session. The host +// owns the prompt rules; the browser only sends the draft. + +import { CatalogStore } from "./catalog.js"; +import { + addSection, + classifyDraftError, + clearDraft, + emptyDraft, + loadDraft, + moveSection, + removeSection, + saveDraft, + setPreset, + setProject, + setSkill, + updateSection, + wireDraft, +} from "./draft.js"; +import type { DraftStorage } from "./draft.js"; +import { newReqId } from "./protocol.js"; +import type { PromptDraft } from "./protocol.js"; +import { Sheet, esc } from "./sheet.js"; + +export interface BuilderCallbacks { + /** Returns false when the frame could not be sent (no connection). */ + onCompose: (draft: PromptDraft, req: string) => boolean; + onSend: (draft: PromptDraft, req: string) => boolean; +} + +interface BuilderState { + draft: PromptDraft; + preview: string | null; + error: string | null; + presetError: string | null; + skillsError: string | null; + projectError: string | null; + previewReq: string | null; + sendReq: string | null; +} + +function freshState(): BuilderState { + return { + draft: emptyDraft(), + preview: null, + error: null, + presetError: null, + skillsError: null, + projectError: null, + previewReq: null, + sendReq: null, + }; +} + +export class Builder { + private readonly catalog: CatalogStore; + private readonly callbacks: BuilderCallbacks; + private readonly storage: DraftStorage | null | undefined; + private readonly sheet: Sheet; + private state: BuilderState = freshState(); + private unsubscribe: (() => void) | null = null; + private body: HTMLElement | null = null; + private initialized = false; + + constructor(catalog: CatalogStore, callbacks: BuilderCallbacks, storage?: DraftStorage | null) { + this.catalog = catalog; + this.callbacks = callbacks; + this.storage = storage; + this.sheet = new Sheet({ kind: "builder", title: "Prompt Builder" }, () => this.close()); + } + + get isOpen(): boolean { + return this.sheet.isOpen; + } + + open(parent: HTMLElement): void { + if (this.sheet.isOpen) { + return; + } + // The in-memory draft wins for the life of the page; sessionStorage + // only seeds the first open (a reload) and mirrors every edit. + const previous = this.state.draft; + this.unsubscribe?.(); + this.unsubscribe = null; + this.state = freshState(); + this.state.draft = this.initialized ? previous : loadDraft(this.storage); + this.initialized = true; + if (this.state.draft.sections.length === 0) { + this.state.draft = addSection(this.state.draft); + } + this.body = this.sheet.mount(parent); + this.render(); + this.unsubscribe = this.catalog.subscribe(() => { + if (this.sheet.isOpen) { + this.render(); + } + }); + } + + // Closing keeps the draft: memory and sessionStorage both still hold it. + close(): void { + this.state.previewReq = null; + this.state.sendReq = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.body = null; + this.sheet.close(); + } + + handleComposed(req: string, text: string): boolean { + if (this.state.previewReq === null || this.state.previewReq !== req) { + return false; + } + this.state.previewReq = null; + this.state.preview = text; + const pre = this.body?.querySelector("#builder-composed"); + if (pre) { + pre.textContent = text; + pre.hidden = false; + } + return true; + } + + handleOk(req: string): boolean { + if (this.state.sendReq === null || this.state.sendReq !== req) { + return false; + } + this.state.sendReq = null; + this.state.draft = emptyDraft(); + clearDraft(this.storage); + this.state.preview = null; + this.close(); + return true; + } + + handleError(message: string, req: string | null, code: string): boolean { + const previewMatch = this.state.previewReq !== null && (req === null || this.state.previewReq === req); + const sendMatch = this.state.sendReq !== null && (req === null || this.state.sendReq === req); + if (!previewMatch && !sendMatch) { + return false; + } + if (previewMatch) { + this.state.previewReq = null; + } + if (sendMatch) { + this.state.sendReq = null; + } + const classified = classifyDraftError(code, message); + if (classified.field === "preset") { + this.state.presetError = classified.message; + } else if (classified.field === "skills") { + this.state.skillsError = classified.message; + } else if (classified.field === "project") { + this.state.projectError = classified.message; + } else { + this.state.error = classified.message; + } + this.renderErrors(); + return true; + } + + private save(): void { + saveDraft(this.state.draft, this.storage); + } + + private render(): void { + const body = this.body; + if (!body) { + return; + } + body.innerHTML = ` +
    +
    +

    Sections

    +
      ${this.sectionsHtml()}
    + +
    + +

    ${esc(this.selectedPreset()?.preview ?? "")}

    + +
    + Skills +
    ${this.skillsHtml()}
    +
    + + + + +
    ${esc(this.state.preview ?? "")}
    +
    + + +
    +
    `; + this.wire(); + this.syncSelects(); + } + + // happy-dom (and some browsers) pick the first non-empty option from + // innerHTML; assign the current state explicitly so the UI is truthful. + private syncSelects(): void { + const body = this.body; + if (!body) { + return; + } + const preset = body.querySelector("#builder-preset"); + const project = body.querySelector("#builder-project"); + if (preset) { + preset.value = this.state.draft.preset ?? ""; + } + if (project) { + project.value = this.state.draft.project ?? ""; + } + } + + private sectionsHtml(): string { + const sections = this.state.draft.sections; + return sections + .map((section, index) => { + const last = index === sections.length - 1; + return ` +
  • + + +
    + + + +
    +
  • `; + }) + .join(""); + } + + private presetOptions(): string { + const presets = this.catalog.presets; + const selected = this.state.draft.preset ?? ""; + const head = ``; + return ( + head + + presets + .map( + (preset) => + ``, + ) + .join("") + ); + } + + // Skills from the catalog plus any the draft still carries, so a skill + // the host no longer lists can still be seen and unchecked. + private skillOptions(): { name: string; label: string; checked: boolean }[] { + const options: { name: string; label: string; checked: boolean }[] = []; + for (const skill of this.catalog.skills) { + options.push({ + name: skill.name, + label: skill.label, + checked: this.state.draft.skills.includes(skill.name), + }); + } + const known = new Set(options.map((option) => option.name)); + for (const name of this.state.draft.skills) { + if (!known.has(name)) { + options.push({ name, label: name, checked: true }); + } + } + return options; + } + + private skillsHtml(): string { + return this.skillOptions() + .map( + (option) => + ``, + ) + .join(""); + } + + private projectOptions(): string { + const projects = this.catalog.projects; + const selected = this.state.draft.project ?? ""; + const head = ``; + return ( + head + + projects + .map( + (project) => + ``, + ) + .join("") + ); + } + + private selectedPreset() { + const name = this.state.draft.preset; + return name === undefined ? null : this.catalog.findPreset(name); + } + + private errorAttr(message: string | null): string { + return message === null ? " hidden" : ""; + } + + private wire(): void { + const body = this.body; + if (!body) { + return; + } + body.querySelector("#builder-form")?.addEventListener("submit", (ev) => { + ev.preventDefault(); + this.send(); + }); + body.querySelector("#builder-add-section")?.addEventListener("click", () => { + this.state.draft = addSection(this.state.draft); + this.save(); + this.invalidatePreview(); + this.render(); + }); + for (const button of body.querySelectorAll(".section-up, .section-down")) { + button.addEventListener("click", () => { + const index = Number(button.dataset.index); + const delta = button.classList.contains("section-up") ? -1 : 1; + this.state.draft = moveSection(this.state.draft, index, delta); + this.save(); + this.invalidatePreview(); + this.render(); + }); + } + for (const button of body.querySelectorAll(".section-remove")) { + button.addEventListener("click", () => { + this.state.draft = removeSection(this.state.draft, Number(button.dataset.index)); + this.save(); + this.invalidatePreview(); + this.render(); + }); + } + for (const input of body.querySelectorAll(".section-title")) { + input.addEventListener("input", () => { + this.state.draft = updateSection(this.state.draft, Number(input.dataset.index), { + title: input.value, + }); + this.save(); + this.invalidatePreview(); + }); + } + for (const area of body.querySelectorAll(".section-content")) { + area.addEventListener("input", () => { + this.state.draft = updateSection(this.state.draft, Number(area.dataset.index), { + content: area.value, + }); + this.save(); + this.invalidatePreview(); + }); + } + body.querySelector("#builder-preset")?.addEventListener("change", (ev) => { + this.state.draft = setPreset(this.state.draft, (ev.target as HTMLSelectElement).value); + this.save(); + this.invalidatePreview(); + const preview = body.querySelector("#builder-preset-preview"); + if (preview) { + preview.textContent = this.selectedPreset()?.preview ?? ""; + } + }); + for (const box of body.querySelectorAll(".builder-skill")) { + box.addEventListener("change", () => { + this.state.draft = setSkill(this.state.draft, box.value, box.checked); + this.save(); + this.invalidatePreview(); + }); + } + body.querySelector("#builder-project")?.addEventListener("change", (ev) => { + this.state.draft = setProject(this.state.draft, (ev.target as HTMLSelectElement).value); + this.save(); + this.invalidatePreview(); + }); + body.querySelector("#builder-preview")?.addEventListener("click", () => this.preview()); + body.querySelector("#builder-send")?.addEventListener("click", () => this.send()); + } + + // The composed text describes the draft it came from; any edit makes it + // stale, so drop it (and any compose still in flight) instead of showing + // the wrong prompt. + private invalidatePreview(): void { + this.clearErrors(); + this.state.preview = null; + this.state.previewReq = null; + const pre = this.body?.querySelector("#builder-composed"); + if (pre) { + pre.textContent = ""; + pre.hidden = true; + } + } + + private preview(): void { + this.clearErrors(); + const req = newReqId(); + this.state.previewReq = req; + if (!this.callbacks.onCompose(wireDraft(this.state.draft), req)) { + this.state.previewReq = null; + this.state.error = "Not connected"; + this.renderErrors(); + } + } + + private send(): void { + this.clearErrors(); + const req = newReqId(); + this.state.sendReq = req; + if (!this.callbacks.onSend(wireDraft(this.state.draft), req)) { + this.state.sendReq = null; + this.state.error = "Not connected"; + this.renderErrors(); + } + } + + private clearErrors(): void { + this.state.error = null; + this.state.presetError = null; + this.state.skillsError = null; + this.state.projectError = null; + this.renderErrors(); + } + + private renderErrors(): void { + this.setError("builder-preset-error", this.state.presetError); + this.setError("builder-skills-error", this.state.skillsError); + this.setError("builder-project-error", this.state.projectError); + this.setError("builder-error", this.state.error); + } + + private setError(id: string, message: string | null): void { + const el = this.body?.querySelector(`#${id}`); + if (!el) { + return; + } + el.textContent = message ?? ""; + el.hidden = message === null || message === ""; + } +} diff --git a/apps/remote/web/src/catalog.ts b/apps/remote/web/src/catalog.ts new file mode 100644 index 0000000..e28bda2 --- /dev/null +++ b/apps/remote/web/src/catalog.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// The host catalog as the shell sees it: clis, projects, presets and +// skills requested on connect and cached for the session. The router +// replaces it on a catalog frame, the shell clears it on a new host, and +// the open dialogs subscribe so their pickers fill in when it arrives. + +import type { Catalog, CatalogCli, CatalogProject, CatalogPreset, CatalogSkill } from "./protocol.js"; + +export function emptyCatalog(): Catalog { + return { clis: [], projects: [], presets: [], skills: [] }; +} + +export class CatalogStore { + private catalog: Catalog = emptyCatalog(); + private loaded = false; + private readonly listeners = new Set<() => void>(); + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + replace(catalog: Catalog): void { + this.catalog = catalog; + this.loaded = true; + this.notify(); + } + + clear(): void { + this.catalog = emptyCatalog(); + this.loaded = false; + this.notify(); + } + + get isLoaded(): boolean { + return this.loaded; + } + + get clis(): CatalogCli[] { + return this.catalog.clis; + } + + get projects(): CatalogProject[] { + return this.catalog.projects; + } + + get presets(): CatalogPreset[] { + return this.catalog.presets; + } + + get skills(): CatalogSkill[] { + return this.catalog.skills; + } + + snapshot(): Catalog { + return this.catalog; + } + + findCli(cli: string): CatalogCli | null { + return this.catalog.clis.find((item) => item.cli === cli) ?? null; + } + + findProject(name: string): CatalogProject | null { + return this.catalog.projects.find((item) => item.name === name) ?? null; + } + + findPreset(name: string): CatalogPreset | null { + return this.catalog.presets.find((item) => item.name === name) ?? null; + } +} diff --git a/apps/remote/web/src/draft.ts b/apps/remote/web/src/draft.ts new file mode 100644 index 0000000..0b32e74 --- /dev/null +++ b/apps/remote/web/src/draft.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 +// The Prompt Builder draft: the model the sheet edits, the pure operations +// that change it, the wire form sent to the host, and its sessionStorage +// persistence. The host composes the final text (compose), so nothing here +// knows canopy's prompt rules. Every storage access is wrapped: a browser +// that denies sessionStorage must not break the sheet. + +import type { PromptDraft, PromptDraftSection } from "./protocol.js"; + +export const DRAFT_STORAGE_KEY = "canopy.remote.prompt-draft.v1"; + +export interface DraftStorage { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; +} + +export type DraftField = "preset" | "skills" | "project"; + +export interface DraftError { + field: DraftField | null; + message: string; +} + +export function emptyDraft(): PromptDraft { + return { sections: [], skills: [] }; +} + +export function addSection(draft: PromptDraft, section?: Partial): PromptDraft { + const next: PromptDraftSection = { title: section?.title ?? "", content: section?.content ?? "" }; + return { ...draft, sections: [...draft.sections, next] }; +} + +export function removeSection(draft: PromptDraft, index: number): PromptDraft { + if (index < 0 || index >= draft.sections.length) { + return draft; + } + return { ...draft, sections: draft.sections.filter((_, at) => at !== index) }; +} + +export function moveSection(draft: PromptDraft, index: number, delta: number): PromptDraft { + const target = index + delta; + if (index < 0 || index >= draft.sections.length || target < 0 || target >= draft.sections.length) { + return draft; + } + const sections = [...draft.sections]; + const [moved] = sections.splice(index, 1); + if (!moved) { + return draft; + } + sections.splice(target, 0, moved); + return { ...draft, sections }; +} + +export function updateSection( + draft: PromptDraft, + index: number, + patch: Partial, +): PromptDraft { + if (index < 0 || index >= draft.sections.length) { + return draft; + } + return { + ...draft, + sections: draft.sections.map((section, at) => (at === index ? { ...section, ...patch } : section)), + }; +} + +export function setPreset(draft: PromptDraft, preset: string | null): PromptDraft { + const next: PromptDraft = { ...draft }; + if (preset === null || preset === "") { + delete next.preset; + } else { + next.preset = preset; + } + return next; +} + +export function setProject(draft: PromptDraft, project: string | null): PromptDraft { + const next: PromptDraft = { ...draft }; + if (project === null || project === "") { + delete next.project; + } else { + next.project = project; + } + return next; +} + +export function setSkill(draft: PromptDraft, name: string, on: boolean): PromptDraft { + if (name === "") { + return draft; + } + const present = draft.skills.includes(name); + if (on === present) { + return draft; + } + const skills = on ? [...draft.skills, name] : draft.skills.filter((skill) => skill !== name); + return { ...draft, skills }; +} + +export function toggleSkill(draft: PromptDraft, name: string): PromptDraft { + return setSkill(draft, name, !draft.skills.includes(name)); +} + +export function isBlankSection(section: PromptDraftSection): boolean { + return section.title.trim() === "" && section.content.trim() === ""; +} + +// The wire form: fully empty sections are dropped, skills is always an +// array, and an empty preset/project is omitted rather than sent as "". +export function wireDraft(draft: PromptDraft): PromptDraft { + const wire: PromptDraft = { + sections: draft.sections.filter((section) => !isBlankSection(section)), + skills: [...new Set(draft.skills.filter((name) => name !== ""))], + }; + if (draft.preset !== undefined && draft.preset !== "") { + wire.preset = draft.preset; + } + if (draft.project !== undefined && draft.project !== "") { + wire.project = draft.project; + } + return wire; +} + +// Field-specific errors: codes first, then the message's own wording, so +// the message lands beside the field it is about. +export function classifyDraftError(code: string, message: string): DraftError { + if (code === "unknown_preset") { + return { field: "preset", message }; + } + if (code === "unknown_skill") { + return { field: "skills", message }; + } + if (code === "unknown_project") { + return { field: "project", message }; + } + const text = message.toLowerCase(); + if (/unknown|not found|no such|missing/.test(text)) { + if (text.includes("preset")) { + return { field: "preset", message }; + } + if (text.includes("skill")) { + return { field: "skills", message }; + } + if (text.includes("project") || text.includes("workdir")) { + return { field: "project", message }; + } + } + return { field: null, message }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function defaultStorage(): DraftStorage | null { + try { + return globalThis.sessionStorage ?? null; + } catch { + return null; + } +} + +function resolveStorage(storage: DraftStorage | null | undefined): DraftStorage | null { + return storage === undefined ? defaultStorage() : storage; +} + +export function parseStoredDraft(raw: string | null): PromptDraft | null { + if (raw === null || raw === "") { + return null; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(value)) { + return null; + } + const sections: PromptDraftSection[] = []; + if (Array.isArray(value.sections)) { + for (const item of value.sections) { + if (isRecord(item) && typeof item.title === "string" && typeof item.content === "string") { + sections.push({ title: item.title, content: item.content }); + } + } + } + const skills = Array.isArray(value.skills) + ? value.skills.filter((name): name is string => typeof name === "string" && name !== "") + : []; + const draft: PromptDraft = { sections, skills }; + if (typeof value.preset === "string" && value.preset !== "") { + draft.preset = value.preset; + } + if (typeof value.project === "string" && value.project !== "") { + draft.project = value.project; + } + return draft; +} + +export function loadDraft(storage?: DraftStorage | null): PromptDraft { + const store = resolveStorage(storage); + if (!store) { + return emptyDraft(); + } + try { + return parseStoredDraft(store.getItem(DRAFT_STORAGE_KEY)) ?? emptyDraft(); + } catch { + return emptyDraft(); + } +} + +export function saveDraft(draft: PromptDraft, storage?: DraftStorage | null): void { + const store = resolveStorage(storage); + if (!store) { + return; + } + try { + store.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); + } catch { + // Storage unavailable or full: the in-memory draft is still the truth. + } +} + +export function clearDraft(storage?: DraftStorage | null): void { + const store = resolveStorage(storage); + if (!store) { + return; + } + try { + store.removeItem(DRAFT_STORAGE_KEY); + } catch { + // Nothing to recover from. + } +} diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts index 25e8d06..ec17b73 100644 --- a/apps/remote/web/src/fake-host.ts +++ b/apps/remote/web/src/fake-host.ts @@ -1,12 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // Test-only entry behind fake.html (served by vite dev, never built): // the session shell driven by a fake host over an in-memory channel. -// Playwright drives it through window.__fake. +// Playwright drives it through window.__fake. It answers catalog, spawn, +// compose and prompt exactly like the daemon's session protocol. import { createShell } from "./app.js"; +import { CatalogStore } from "./catalog.js"; import { createChannelHandler } from "./channel.js"; import { b64encode } from "./crypto.js"; -import { buildList, newReqId } from "./protocol.js"; +import { buildCatalog, buildList, newReqId } from "./protocol.js"; +import type { PromptDraft, SessionItem } from "./protocol.js"; import { createFrameRouter } from "./router.js"; import { SessionController } from "./session.js"; import { SessionStore } from "./sessions.js"; @@ -14,39 +17,117 @@ import "./styles.css"; const b64 = (text: string): string => b64encode(new TextEncoder().encode(text)); -const SESSION = { - id: "smoke", - name: "smoke", - cli: "muse", - model: "spark", - workdir: "/home/u/demo", - status: "running", - last_output_at: Math.floor(Date.now() / 1000) - 30, +const CATALOG = { + clis: [ + { cli: "muse", label: "Muse", models: ["spark", "core"] }, + { cli: "claude", label: "Claude", models: [] }, + ], + projects: [ + { name: "demo", path: "/home/u/demo" }, + { name: "other", path: "/home/u/other" }, + ], + presets: [{ name: "review", preview: "Review the current diff, then fix what you find." }], + skills: [ + { name: "tdd", label: "TDD" }, + { name: "docs", label: "Docs" }, + ], }; +function composeText(draft: PromptDraft): string { + const parts: string[] = []; + if (draft.preset) { + parts.push(`preset: ${draft.preset}`); + } + for (const section of draft.sections) { + parts.push(`## ${section.title}\n${section.content}`); + } + if (draft.skills.length > 0) { + parts.push(`skills: ${draft.skills.join(", ")}`); + } + if (draft.project) { + parts.push(`project: ${draft.project}`); + } + return parts.join("\n\n"); +} + function main(): void { const root = document.getElementById("app"); if (!root) { throw new Error("Missing #app element"); } const store = new SessionStore(); + const catalog = new CatalogStore(); const sent: unknown[] = []; let seq = 7; + let nextSpawn = 0; let promptError: { code: string; message: string } | null = null; + const sessions: SessionItem[] = [ + { + id: "smoke", + name: "smoke", + cli: "muse", + model: "spark", + workdir: "/home/u/demo", + status: "running", + lastOutputAt: Date.now() - 30_000, + }, + ]; + const deliver = (frame: unknown): void => { frames.push(JSON.stringify(frame)); }; + const sessionItems = () => + sessions.map((item) => ({ + id: item.id, + name: item.name, + cli: item.cli, + model: item.model, + workdir: item.workdir, + status: item.status, + last_output_at: item.lastOutputAt === null ? null : Math.floor(item.lastOutputAt / 1000), + })); + const send = (data: string): boolean => { - const frame = JSON.parse(data) as { t: string; id?: string; req?: string; text?: string }; + const frame = JSON.parse(data) as { + t: string; + id?: string; + req?: string; + text?: string; + cli?: string; + model?: string; + workdir?: string; + name?: string; + draft?: PromptDraft; + }; sent.push(frame); if (frame.t === "list") { - deliver({ t: "sessions", req: frame.req, items: [SESSION] }); - } else if (frame.t === "attach" && frame.id === "smoke") { + deliver({ t: "sessions", req: frame.req, items: sessionItems() }); + } else if (frame.t === "catalog") { + deliver({ t: "catalog", req: frame.req, ...CATALOG }); + } else if (frame.t === "attach" && typeof frame.id === "string") { const full = b64("$ muse run\nhello from the host\n"); - deliver({ t: "snapshot", id: "smoke", cols: 80, rows: 24, seq, data: full.slice(0, 10) }); - deliver({ t: "snapshot_more", id: "smoke", data: full.slice(10), last: true }); + deliver({ t: "snapshot", id: frame.id, cols: 80, rows: 24, seq, data: full.slice(0, 10) }); + deliver({ t: "snapshot_more", id: frame.id, data: full.slice(10), last: true }); + } else if (frame.t === "spawn") { + nextSpawn += 1; + const id = `spawned-${nextSpawn}`; + const item: SessionItem = { + id, + name: frame.name ?? id, + cli: frame.cli ?? "", + model: frame.model ?? "", + workdir: frame.workdir ?? "", + status: "running", + lastOutputAt: null, + }; + sessions.push(item); + deliver({ t: "spawned", req: frame.req, id }); + deliver({ t: "session_added", item: sessionItems()[sessions.length - 1] }); + } else if (frame.t === "compose") { + const draft = frame.draft ?? { sections: [], skills: [] }; + deliver({ t: "composed", req: frame.req, text: composeText(draft) }); } else if (frame.t === "prompt" && typeof frame.req === "string") { if (promptError) { deliver({ t: "error", req: frame.req, code: promptError.code, message: promptError.message }); @@ -58,32 +139,41 @@ function main(): void { return true; }; - const shell = createShell(root, store, { - onPair: () => {}, - onConnect: () => {}, - onDisconnect: () => {}, - onRetry: () => {}, - onPairAgain: () => {}, - onOpenSession: (id) => { - shell.showSession(id, store.get(id)?.name ?? id); - controller.open(id); - }, - onLeaveSession: () => { - controller.close(); - shell.showSessions(); - }, - onKeyPress: (def) => { - controller.sendInputBytes(new Uint8Array(def.bytes)); + const shell = createShell( + root, + store, + { + onPair: () => {}, + onConnect: () => {}, + onDisconnect: () => {}, + onRetry: () => {}, + onPairAgain: () => {}, + onOpenSession: (id) => { + shell.showSession(id, store.get(id)?.name ?? id); + controller.open(id); + }, + onLeaveSession: () => { + controller.close(); + shell.showSessions(); + }, + onKeyPress: (def) => { + controller.sendInputBytes(new Uint8Array(def.bytes)); + }, + onTerminalInput: (text) => { + controller.sendInputText(text); + }, + onComposerSend: (text, req) => { + controller.sendPrompt(text, req); + }, + onSpawn: (spawn, req) => { + const frame = { t: "spawn", req, kind: "agent", ...spawn }; + return send(JSON.stringify(frame)); + }, + onCompose: (draft, req) => send(JSON.stringify({ t: "compose", req, draft })), + onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req), }, - onTerminalInput: (text) => { - controller.sendInputText(text); - }, - onComposerSend: (text, req) => { - controller.sendPrompt(text, req); - }, - onNew: () => {}, - onBuilder: () => {}, - }); + { catalog }, + ); const controller = new SessionController({ send, @@ -95,7 +185,7 @@ function main(): void { }); // The smoke test drives the same routing the app uses. - const router = createFrameRouter({ store, controller, shell }); + const router = createFrameRouter({ store, controller, shell, catalog }); const frames = createChannelHandler({ onSnapshot: router.handleSnapshot, onFrame: router.handleFrame, @@ -106,6 +196,7 @@ function main(): void { shell.setConnectionState("connected"); shell.showSessions(); send(buildList(newReqId())); + send(buildCatalog(newReqId())); window.__fake = { sent: () => [...sent], diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts index a815c28..12b48fe 100644 --- a/apps/remote/web/src/main.ts +++ b/apps/remote/web/src/main.ts @@ -2,16 +2,16 @@ // Browser entry point: auth check, device key, relay socket, pairing, // and the reconnecting host connection behind the session shell. Joined // snapshots feed the open session; every other protocol frame is routed -// to the session store, the composer, or the window events the Prompt -// Builder (next spec) listens for. +// to the session store, the catalog cache, a sheet, or the composer. import { createShell } from "./app.js"; import type { Shell } from "./app.js"; +import { CatalogStore } from "./catalog.js"; import { createChannelHandler } from "./channel.js"; import { generateDeviceKey } from "./crypto.js"; import type { DeviceKey } from "./crypto.js"; import { keyBytes } from "./keys.js"; -import { buildList, newReqId } from "./protocol.js"; +import { buildCatalog, buildCompose, buildList, buildSpawn, newReqId } from "./protocol.js"; import { runPairing } from "./pairing.js"; import { ConnectionManager } from "./reconnect.js"; import { RelaySocket, defaultRelayUrl, parseHostsList } from "./relay.js"; @@ -71,6 +71,7 @@ async function main(): Promise { const relay = new RelaySocket(defaultRelayUrl()); const store = new SessionStore(); + const catalog = new CatalogStore(); let onlineHosts: OnlineHost[] = []; let manager: ConnectionManager | null = null; @@ -83,6 +84,7 @@ async function main(): Promise { onDisconnect: () => void doDisconnect(), onRetry: () => manager?.retryNow(), onPairAgain: () => { + catalog.clear(); manager?.stop(); manager = null; shell.showPair(); @@ -106,15 +108,11 @@ async function main(): Promise { shell.composerError("Not connected", req); } }, - // The New dialog and the Prompt Builder arrive with the next spec; - // until then the buttons announce themselves on the window. - onNew: () => { - globalThis.dispatchEvent(new CustomEvent("canopy:new")); - }, - onBuilder: () => { - globalThis.dispatchEvent(new CustomEvent("canopy:builder")); - }, + onSpawn: (spawn, req) => manager?.send(buildSpawn(req, spawn)) ?? false, + onCompose: (draft, req) => manager?.send(buildCompose(req, draft)) ?? false, + onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req), }, + { catalog }, ); const controller = new SessionController({ @@ -130,12 +128,13 @@ async function main(): Promise { shell.setPairedHosts(await listHosts()); }; - const router = createFrameRouter({ store, controller, shell }); + const router = createFrameRouter({ store, controller, shell, catalog }); function startSession(host: HostRecord): void { // Detach while the old connection is still open so the host sees it. controller.close(); manager?.stop(); + catalog.clear(); shell.setHostLabel(host.label); shell.showSessions(); const frames = createChannelHandler({ @@ -148,7 +147,9 @@ async function main(): Promise { shell.setConnectionState(state); if (state === "connected") { // A fresh connect and a reconnect both land here: refresh the - // list, and the open session re-attaches for a fresh snapshot. + // catalog and the list, and the open session re-attaches for a + // fresh snapshot. + next.send(buildCatalog(newReqId())); next.send(buildList(newReqId())); controller.reattach(); } @@ -206,6 +207,7 @@ async function main(): Promise { controller.close(); manager?.stop(); manager = null; + catalog.clear(); const stored = await listHosts(); await refreshPaired(); if (stored.length === 0) { diff --git a/apps/remote/web/src/new-dialog.ts b/apps/remote/web/src/new-dialog.ts new file mode 100644 index 0000000..4c6e4fc --- /dev/null +++ b/apps/remote/web/src/new-dialog.ts @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 +// The New dialog: start an agent session on the host. Harness, optional +// model, required project, and an optional name and first prompt, all +// picked from the host catalog. Create sends spawn{req,kind:"agent",...}; +// a matching spawned{req,id} closes the dialog and the shell opens the +// session. An error keeps the form exactly as it was. + +import { CatalogStore } from "./catalog.js"; +import { classifyDraftError } from "./draft.js"; +import { newReqId } from "./protocol.js"; +import type { SpawnRequest } from "./protocol.js"; +import { Sheet, esc } from "./sheet.js"; + +export interface NewDialogCallbacks { + /** Returns false when the frame could not be sent (no connection). */ + onSpawn: (spawn: SpawnRequest, req: string) => boolean; +} + +interface NewForm { + cli: string; + model: string; + project: string; + name: string; + prompt: string; +} + +const EMPTY_FORM: NewForm = { cli: "", model: "", project: "", name: "", prompt: "" }; + +export class NewDialog { + private readonly catalog: CatalogStore; + private readonly callbacks: NewDialogCallbacks; + private readonly sheet: Sheet; + private form: NewForm = { ...EMPTY_FORM }; + private pending: string | null = null; + private unsubscribe: (() => void) | null = null; + private body: HTMLElement | null = null; + private error: string | null = null; + private projectError: string | null = null; + + constructor(catalog: CatalogStore, callbacks: NewDialogCallbacks) { + this.catalog = catalog; + this.callbacks = callbacks; + this.sheet = new Sheet({ kind: "new", title: "New session" }, () => this.close()); + } + + get isOpen(): boolean { + return this.sheet.isOpen; + } + + open(parent: HTMLElement): void { + if (this.sheet.isOpen) { + return; + } + this.unsubscribe?.(); + this.unsubscribe = null; + this.body = this.sheet.mount(parent); + this.error = null; + this.projectError = null; + this.pending = null; + this.defaultsFromCatalog(); + this.render(); + this.unsubscribe = this.catalog.subscribe(() => { + if (!this.sheet.isOpen) { + return; + } + this.defaultsFromCatalog(); + this.render(); + }); + } + + close(): void { + this.pending = null; + this.error = null; + this.projectError = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.body = null; + this.sheet.close(); + } + + handleSpawned(req: string): boolean { + if (this.pending === null || this.pending !== req) { + return false; + } + this.pending = null; + this.form = { ...EMPTY_FORM }; + this.close(); + return true; + } + + handleError(message: string, req: string | null, code: string): boolean { + if (this.pending === null) { + return false; + } + if (req !== null && req !== this.pending) { + return false; + } + this.pending = null; + const classified = classifyDraftError(code, message); + if (classified.field === "project") { + this.showProjectError(classified.message); + } else { + this.showError(classified.message); + } + return true; + } + + // Keep the picks that still exist, default the rest: the first harness, + // and the only project when the host offers exactly one. + private defaultsFromCatalog(): void { + const clis = this.catalog.clis; + const cli = clis.find((item) => item.cli === this.form.cli) ?? clis[0] ?? null; + this.form.cli = cli?.cli ?? ""; + const models = cli?.models ?? []; + if (this.form.model !== "" && !models.includes(this.form.model)) { + this.form.model = ""; + } + const projects = this.catalog.projects; + const project = + projects.find((item) => item.name === this.form.project) ?? + (projects.length === 1 ? projects[0] : null); + this.form.project = project?.name ?? ""; + } + + private render(): void { + const body = this.body; + if (!body) { + return; + } + const clis = this.catalog.clis; + const projects = this.catalog.projects; + body.innerHTML = ` +
    + + + + + + + +
    + + +
    +
    `; + this.wire(); + this.syncSelects(); + } + + // happy-dom (and some browsers) pick the first non-empty option from + // innerHTML; assign the current state explicitly so the UI is truthful. + private syncSelects(): void { + const body = this.body; + if (!body) { + return; + } + const cli = body.querySelector("#new-cli"); + const model = body.querySelector("#new-model"); + const project = body.querySelector("#new-project"); + if (cli) { + cli.value = this.form.cli; + } + if (model) { + model.value = this.form.model; + } + if (project) { + project.value = this.form.project; + } + } + + private cliOptions(): string { + const clis = this.catalog.clis; + if (clis.length === 0) { + return ``; + } + return clis + .map( + (item) => + ``, + ) + .join(""); + } + + private modelOptions(): string { + const models = this.catalog.findCli(this.form.cli)?.models ?? []; + const head = ``; + return ( + head + + models + .map( + (model) => + ``, + ) + .join("") + ); + } + + private projectOptions(): string { + const projects = this.catalog.projects; + if (projects.length === 0) { + return ``; + } + const head = ``; + return ( + head + + projects + .map( + (project) => + ``, + ) + .join("") + ); + } + + private wire(): void { + const body = this.body; + if (!body) { + return; + } + body.querySelector("#new-form")?.addEventListener("submit", (ev) => { + ev.preventDefault(); + this.submit(); + }); + body.querySelector("#new-cancel")?.addEventListener("click", () => this.close()); + body.querySelector("#new-cli")?.addEventListener("change", (ev) => { + this.form.cli = (ev.target as HTMLSelectElement).value; + this.form.model = ""; + this.renderModelOptions(); + }); + body.querySelector("#new-model")?.addEventListener("change", (ev) => { + this.form.model = (ev.target as HTMLSelectElement).value; + }); + body.querySelector("#new-project")?.addEventListener("change", (ev) => { + this.form.project = (ev.target as HTMLSelectElement).value; + this.showProjectError(null); + }); + body.querySelector("#new-name")?.addEventListener("input", (ev) => { + this.form.name = (ev.target as HTMLInputElement).value; + }); + body.querySelector("#new-prompt")?.addEventListener("input", (ev) => { + this.form.prompt = (ev.target as HTMLTextAreaElement).value; + }); + } + + private renderModelOptions(): void { + const select = this.body?.querySelector("#new-model"); + if (select) { + select.innerHTML = this.modelOptions(); + select.value = this.form.model; + } + } + + // The DOM is the truth at submit time: reads fields the way the pair form + // does, so a programmatic value with no input event still counts. + private syncFields(): void { + const body = this.body; + if (!body) { + return; + } + const cli = body.querySelector("#new-cli"); + const model = body.querySelector("#new-model"); + const project = body.querySelector("#new-project"); + const name = body.querySelector("#new-name"); + const prompt = body.querySelector("#new-prompt"); + if (cli) { + this.form.cli = cli.value; + } + if (model) { + this.form.model = model.value; + } + if (project) { + this.form.project = project.value; + } + if (name) { + this.form.name = name.value; + } + if (prompt) { + this.form.prompt = prompt.value; + } + } + + private submit(): void { + this.syncFields(); + this.showError(""); + this.showProjectError(null); + if (this.form.cli === "") { + this.showError("No harness selected"); + return; + } + const project = this.catalog.findProject(this.form.project); + if (project === null) { + this.showProjectError("Choose a project"); + return; + } + const spawn: SpawnRequest = { cli: this.form.cli, workdir: project.path }; + if (this.form.model !== "") { + spawn.model = this.form.model; + } + const name = this.form.name.trim(); + if (name !== "") { + spawn.name = name; + } + const prompt = this.form.prompt.trim(); + if (prompt !== "") { + spawn.prompt = prompt; + } + const req = newReqId(); + this.pending = req; + if (!this.callbacks.onSpawn(spawn, req)) { + this.pending = null; + this.showError("Not connected"); + } + } + + private showError(message: string): void { + this.error = message === "" ? null : message; + const el = this.body?.querySelector("#new-error"); + if (el) { + el.textContent = message; + } + } + + private showProjectError(message: string | null): void { + this.projectError = message; + const el = this.body?.querySelector("#new-project-error"); + if (el) { + el.textContent = message ?? ""; + el.hidden = message === null; + } + } +} diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts index 9e9f77e..0731a95 100644 --- a/apps/remote/web/src/protocol.ts +++ b/apps/remote/web/src/protocol.ts @@ -29,6 +29,58 @@ export interface ReplyError { message: string; } +// The host's catalog: everything the New dialog and the Prompt Builder +// offer. The host owns the values; the browser only renders them. +export interface CatalogCli { + cli: string; + label: string; + models: string[]; +} + +export interface CatalogProject { + name: string; + path: string; +} + +export interface CatalogPreset { + name: string; + preview: string; +} + +export interface CatalogSkill { + name: string; + label: string; +} + +export interface Catalog { + clis: CatalogCli[]; + projects: CatalogProject[]; + presets: CatalogPreset[]; + skills: CatalogSkill[]; +} + +// A prompt draft is what the Prompt Builder sends; the host turns it into +// text with the same function the TUI uses. The browser never composes. +export interface PromptDraftSection { + title: string; + content: string; +} + +export interface PromptDraft { + sections: PromptDraftSection[]; + preset?: string; + skills: string[]; + project?: string; +} + +export interface SpawnRequest { + cli: string; + model?: string; + workdir: string; + name?: string; + prompt?: string; +} + function isRecord(frame: unknown): frame is Record { return typeof frame === "object" && frame !== null; } @@ -163,10 +215,131 @@ export function parseError(frame: unknown): ReplyError | null { return { req: typeof frame.req === "string" ? frame.req : null, code: frame.code, message: frame.message }; } +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const item of value) { + if (typeof item === "string" && item !== "") { + out.push(item); + } + } + return out; +} + +function parseCli(value: unknown): CatalogCli | null { + if (!isRecord(value) || typeof value.cli !== "string" || value.cli === "") { + return null; + } + const label = typeof value.label === "string" && value.label !== "" ? value.label : value.cli; + return { cli: value.cli, label, models: stringList(value.models) }; +} + +function parseProject(value: unknown): CatalogProject | null { + if (!isRecord(value) || typeof value.name !== "string" || value.name === "") { + return null; + } + if (typeof value.path !== "string" || value.path === "") { + return null; + } + return { name: value.name, path: value.path }; +} + +function parsePreset(value: unknown): CatalogPreset | null { + if (!isRecord(value) || typeof value.name !== "string" || value.name === "") { + return null; + } + return { name: value.name, preview: asString(value.preview) }; +} + +function parseSkill(value: unknown): CatalogSkill | null { + if (!isRecord(value) || typeof value.name !== "string" || value.name === "") { + return null; + } + const label = typeof value.label === "string" && value.label !== "" ? value.label : value.name; + return { name: value.name, label }; +} + +function collect(value: unknown, parse: (item: unknown) => T | null): T[] { + if (!Array.isArray(value)) { + return []; + } + const out: T[] = []; + for (const item of value) { + const parsed = parse(item); + if (parsed) { + out.push(parsed); + } + } + return out; +} + +// A catalog frame is best-effort: malformed entries are dropped instead of +// failing the whole reply, so one bad project never empties the pickers. +export function parseCatalog(frame: unknown): Catalog | null { + if (frameType(frame) !== "catalog" || !isRecord(frame)) { + return null; + } + return { + clis: collect(frame.clis, parseCli), + projects: collect(frame.projects, parseProject), + presets: collect(frame.presets, parsePreset), + skills: collect(frame.skills, parseSkill), + }; +} + +export function parseSpawned(frame: unknown): { req: string; id: string } | null { + if (frameType(frame) !== "spawned" || !isRecord(frame)) { + return null; + } + if (typeof frame.req !== "string" || typeof frame.id !== "string" || frame.id === "") { + return null; + } + return { req: frame.req, id: frame.id }; +} + +export function parseComposed(frame: unknown): { req: string; text: string } | null { + if (frameType(frame) !== "composed" || !isRecord(frame)) { + return null; + } + if (typeof frame.req !== "string" || typeof frame.text !== "string") { + return null; + } + return { req: frame.req, text: frame.text }; +} + export function buildList(req: string): string { return JSON.stringify({ t: "list", req }); } +export function buildCatalog(req: string): string { + return JSON.stringify({ t: "catalog", req }); +} + +export function buildSpawn(req: string, spawn: SpawnRequest): string { + const frame: Record = { t: "spawn", req, kind: "agent", cli: spawn.cli }; + if (spawn.model !== undefined && spawn.model !== "") { + frame.model = spawn.model; + } + frame.workdir = spawn.workdir; + if (spawn.name !== undefined && spawn.name !== "") { + frame.name = spawn.name; + } + if (spawn.prompt !== undefined && spawn.prompt !== "") { + frame.prompt = spawn.prompt; + } + return JSON.stringify(frame); +} + +export function buildCompose(req: string, draft: PromptDraft): string { + return JSON.stringify({ t: "compose", req, draft }); +} + +export function buildPromptDraft(req: string, id: string, draft: PromptDraft): string { + return JSON.stringify({ t: "prompt", req, id, draft }); +} + export function buildAttach(id: string): string { return JSON.stringify({ t: "attach", id }); } diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts index a3e68e1..75b8198 100644 --- a/apps/remote/web/src/router.ts +++ b/apps/remote/web/src/router.ts @@ -1,13 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Frame routing between the data channel and the session shell: joined // snapshots mount and feed the terminal, every other protocol frame goes -// to the session store, the open session, or the composer. Extracted from -// the entry point so tests pin the exact wiring the app uses; main.ts and -// the fake host both route through here. +// to the session store, the open session, the catalog cache, a sheet, or +// the composer. Extracted from the entry point so tests pin the exact +// wiring the app uses; main.ts and the fake host both route through here. import type { Shell } from "./app.js"; +import type { CatalogStore } from "./catalog.js"; import type { JoinedSnapshot } from "./channel.js"; import { + parseCatalog, + parseComposed, parseError, parseExited, parseOk, @@ -15,6 +18,7 @@ import { parseSessionAdded, parseSessionRemoved, parseSessions, + parseSpawned, } from "./protocol.js"; import type { SessionController } from "./session.js"; import type { SessionStore } from "./sessions.js"; @@ -28,19 +32,18 @@ export interface FrameRouterDeps { store: SessionStore; controller: SessionController; shell: Shell; + catalog: CatalogStore; } export function createFrameRouter(deps: FrameRouterDeps): FrameRouter { - const { store, controller, shell } = deps; + const { store, controller, shell, catalog } = deps; function handleSnapshot(snapshot: JoinedSnapshot): void { - globalThis.dispatchEvent(new CustomEvent("canopy:snapshot", { detail: snapshot })); shell.ensureTerminal(snapshot.id, snapshot.cols, snapshot.rows); controller.handleSnapshot(snapshot); } function handleFrame(frame: unknown): void { - globalThis.dispatchEvent(new CustomEvent("canopy:frame", { detail: frame })); const sessions = parseSessions(frame); if (sessions) { store.replace(sessions); @@ -67,14 +70,31 @@ export function createFrameRouter(deps: FrameRouterDeps): FrameRouter { controller.handleOut(out); return; } + const catalogReply = parseCatalog(frame); + if (catalogReply) { + catalog.replace(catalogReply); + return; + } + const spawned = parseSpawned(frame); + if (spawned) { + shell.sheetSpawned(spawned.req, spawned.id); + return; + } + const composed = parseComposed(frame); + if (composed) { + shell.sheetComposed(composed.req, composed.text); + return; + } const ok = parseOk(frame); if (ok !== null) { shell.composerOk(ok); + shell.sheetOk(ok); return; } const error = parseError(frame); if (error) { shell.composerError(error.message, error.req); + shell.sheetError(error.message, error.req, error.code); } } diff --git a/apps/remote/web/src/session.ts b/apps/remote/web/src/session.ts index c026a2f..60461b2 100644 --- a/apps/remote/web/src/session.ts +++ b/apps/remote/web/src/session.ts @@ -12,10 +12,11 @@ import { buildInput, buildInputText, buildPrompt, + buildPromptDraft, decodeSnapshotData, decodeUtf8, } from "./protocol.js"; -import type { OutFrame } from "./protocol.js"; +import type { OutFrame, PromptDraft } from "./protocol.js"; export interface SessionTerminal { write: (text: string) => void; @@ -96,6 +97,15 @@ export class SessionController { return this.callbacks.send(buildPrompt(req, this.openId, text)); } + // The Prompt Builder path: the host composes the final text from the + // draft, so the browser never reimplements canopy's prompt rules. + sendPromptDraft(draft: PromptDraft, req: string): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildPromptDraft(req, this.openId, draft)); + } + handleSnapshot(snapshot: JoinedSnapshot): void { if (this.openId === null || snapshot.id !== this.openId) { return; diff --git a/apps/remote/web/src/sheet.ts b/apps/remote/web/src/sheet.ts new file mode 100644 index 0000000..e88bd32 --- /dev/null +++ b/apps/remote/web/src/sheet.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// The modal sheet shared by the New dialog and the Prompt Builder: a fixed +// overlay with a titled panel, a close button and an empty body the caller +// fills. Esc and the close button call the owner's close; the shell closes +// both sheets on any route change. + +export interface SheetOptions { + /** Stable name used for ids and the data-sheet attribute. */ + kind: string; + title: string; +} + +export function esc(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export class Sheet { + private readonly kind: string; + private readonly title: string; + private readonly onClose: () => void; + private overlay: HTMLElement | null = null; + + constructor(options: SheetOptions, onClose: () => void) { + this.kind = options.kind; + this.title = options.title; + this.onClose = onClose; + } + + get isOpen(): boolean { + return this.overlay !== null && this.overlay.isConnected; + } + + mount(parent: HTMLElement): HTMLElement { + this.close(); + const overlay = document.createElement("div"); + overlay.className = "sheet-overlay"; + overlay.dataset.sheet = this.kind; + overlay.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + ev.preventDefault(); + this.onClose(); + } + }); + + const panel = document.createElement("section"); + panel.className = "sheet"; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("aria-label", this.title); + + const head = document.createElement("header"); + head.className = "sheet-head"; + const heading = document.createElement("h2"); + heading.textContent = this.title; + const close = document.createElement("button"); + close.type = "button"; + close.className = "sheet-close"; + close.id = `${this.kind}-close`; + close.setAttribute("aria-label", "Close"); + close.textContent = "×"; + close.addEventListener("click", () => this.onClose()); + head.append(heading, close); + + const body = document.createElement("div"); + body.className = "sheet-body"; + body.id = `${this.kind}-body`; + panel.append(head, body); + overlay.append(panel); + parent.append(overlay); + + this.overlay = overlay; + return body; + } + + close(): void { + this.overlay?.remove(); + this.overlay = null; + } +} diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index 8418b2f..418d17f 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -395,10 +395,188 @@ label { margin-left: auto; } +/* Shared modal sheet: New dialog and Prompt Builder. */ +.sheet-overlay { + position: fixed; + inset: 0; + z-index: 20; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: flex-end; + justify-content: center; +} + +.sheet { + background: var(--bg); + color: var(--ink); + width: 100%; + max-width: 48rem; + max-height: 92dvh; + overflow-y: auto; + border: 1px solid var(--line); + border-radius: var(--radius) var(--radius) 0 0; + padding: 0 1rem calc(1rem + env(safe-area-inset-bottom)); +} + +.sheet-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + background: var(--bg); + padding: 0.75rem 0; + border-bottom: 1px solid var(--line); +} + +.sheet-head h2 { + margin: 0; + font-size: 1.1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sheet-close { + min-width: 2.75rem; + padding: 0.25rem 0.6rem; + font-size: 1.25rem; + line-height: 1; +} + +.sheet-actions { + display: flex; + gap: var(--gap); + justify-content: flex-end; + margin-top: 1rem; +} + +button.primary { + background: var(--accent); + color: var(--accent-ink); + border-color: transparent; +} + +.sheet-error { + color: var(--danger); + font-size: 0.85rem; + margin: 0.5rem 0; + overflow-wrap: anywhere; +} + +.sheet-error[hidden] { + display: none; +} + +.field-error { + color: var(--danger); + font-size: 0.85rem; + margin: -0.5rem 0 0.5rem; + overflow-wrap: anywhere; +} + +.field-error[hidden] { + display: none; +} + +.preset-preview { + color: var(--muted); + font-size: 0.85rem; + white-space: pre-wrap; + margin: 0.25rem 0 0.75rem; +} + +.preset-preview:empty { + display: none; +} + +.builder-form h3 { + margin: 1rem 0 0.5rem; + font-size: 1rem; +} + +.builder-section-list { + list-style: none; + margin: 0; + padding: 0; +} + +.builder-section { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + margin-bottom: var(--gap); +} + +.section-actions { + display: flex; + flex-wrap: wrap; + gap: var(--gap); +} + +.section-actions button { + min-height: 2.25rem; + padding: 0.25rem 0.6rem; +} + +.builder-skills { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + margin: 0.75rem 0; +} + +.skill-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; +} + +.skill { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; +} + +.skill input { + display: inline-block; + width: auto; + min-height: 0; + margin: 0; +} + +.builder-preview { + background: var(--key-bg); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.75rem; + margin: 0.75rem 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.builder-preview[hidden] { + display: none; +} + @media (min-width: 48rem) { .term-pane { height: 60dvh; } + + .sheet-overlay { + align-items: center; + padding: 1rem; + } + + .sheet { + border-radius: var(--radius); + } } @media (prefers-reduced-motion: reduce) { diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index 0bcf305..4528bc7 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createShell } from "../src/app.js"; import type { Shell, ShellCallbacks } from "../src/app.js"; +import { CatalogStore } from "../src/catalog.js"; import { keyBytes } from "../src/keys.js"; import type { KeyDef } from "../src/keys.js"; import { connectFailMessage } from "../src/peer.js"; +import type { PromptDraft, SpawnRequest } from "../src/protocol.js"; import { SessionStore } from "../src/sessions.js"; import type { TerminalFactory, TerminalHandle } from "../src/terminal.js"; import type { PairSubmit } from "../src/ui.js"; @@ -64,14 +66,19 @@ class FakeHandle implements TerminalHandle { describe("createShell", () => { let root: HTMLDivElement; let store: SessionStore; + let catalog: CatalogStore; let shell: Shell; let calls: ShellCallbacks & { pairs: PairSubmit[]; keys: KeyDef[]; inputs: string[]; composerSends: { text: string; req: string }[]; + spawns: { spawn: SpawnRequest; req: string }[]; + composes: { draft: PromptDraft; req: string }[]; + draftSends: { draft: PromptDraft; req: string }[]; }; let handles: FakeHandle[]; + let sendable: boolean; const query = (selector: string): T => { const el = root.querySelector(selector); @@ -87,7 +94,21 @@ describe("createShell", () => { root = document.createElement("div"); document.body.appendChild(root); store = new SessionStore(); + catalog = new CatalogStore(); + catalog.replace({ + clis: [ + { cli: "muse", label: "Muse", models: ["spark"] }, + { cli: "claude", label: "Claude", models: [] }, + ], + projects: [ + { name: "demo", path: "/home/u/demo" }, + { name: "other", path: "/home/u/other" }, + ], + presets: [{ name: "review", preview: "Review the diff." }], + skills: [{ name: "tdd", label: "TDD" }], + }); handles = []; + sendable = true; const factory: TerminalFactory = (_host, cols, rows) => { const handle = new FakeHandle(cols, rows); handles.push(handle); @@ -97,6 +118,9 @@ describe("createShell", () => { const keys: KeyDef[] = []; const inputs: string[] = []; const composerSends: { text: string; req: string }[] = []; + const spawns: { spawn: SpawnRequest; req: string }[] = []; + const composes: { draft: PromptDraft; req: string }[] = []; + const draftSends: { draft: PromptDraft; req: string }[] = []; const signal = { connected: [] as string[], disconnects: 0, @@ -104,14 +128,15 @@ describe("createShell", () => { pairAgains: 0, opened: [] as string[], left: 0, - news: 0, - builders: 0, }; calls = { pairs, keys, inputs, composerSends, + spawns, + composes, + draftSends, onPair: (submit) => pairs.push(submit), onConnect: (hostId) => signal.connected.push(hostId), onDisconnect: () => { @@ -130,11 +155,17 @@ describe("createShell", () => { onKeyPress: (def) => keys.push(def), onTerminalInput: (text) => inputs.push(text), onComposerSend: (text, req) => composerSends.push({ text, req }), - onNew: () => { - signal.news += 1; + onSpawn: (spawn, req) => { + spawns.push({ spawn, req }); + return sendable; + }, + onCompose: (draft, req) => { + composes.push({ draft, req }); + return sendable; }, - onBuilder: () => { - signal.builders += 1; + onSendDraft: (draft, req) => { + draftSends.push({ draft, req }); + return sendable; }, }; ( @@ -142,7 +173,7 @@ describe("createShell", () => { signal: typeof signal; } ).signal = signal; - shell = createShell(root, store, calls, { terminalFactory: factory }); + shell = createShell(root, store, calls, { terminalFactory: factory, catalog }); }); afterEach(() => { @@ -162,8 +193,6 @@ describe("createShell", () => { pairAgains: number; opened: string[]; left: number; - news: number; - builders: number; }; } ).signal; @@ -225,7 +254,9 @@ describe("createShell", () => { row.click(); expect(signal().opened).toEqual(["s1"]); query("#session-new").click(); - expect(signal().news).toBe(1); + expect(root.querySelector("#new-form")).not.toBeNull(); + query("#new-close").click(); + expect(root.querySelector(".sheet-overlay")).toBeNull(); query("#conn-disconnect").click(); expect(signal().disconnects).toBe(1); }); @@ -262,7 +293,8 @@ describe("createShell", () => { expect(query(".composer-input").value).toBe(""); query(".composer-builder").click(); - expect(signal().builders).toBe(1); + expect(root.querySelector("#builder-form")).not.toBeNull(); + query("#builder-close").click(); query("#session-back").click(); expect(signal().left).toBe(1); }); @@ -355,4 +387,86 @@ describe("createShell", () => { query("#term-zoom-out").click(); expect(handles).toHaveLength(1); }); + + it("spawns from the New sheet and opens the spawned session", () => { + shell.showSessions(); + query("#session-new").click(); + expect(root.querySelector("#new-form")).not.toBeNull(); + const project = query("#new-project"); + project.value = "demo"; + project.dispatchEvent(new Event("change", { bubbles: true })); + query("#new-name").value = "build"; + query("#new-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(calls.spawns).toHaveLength(1); + expect(calls.spawns[0]?.spawn).toEqual({ + cli: "muse", + workdir: "/home/u/demo", + name: "build", + }); + shell.sheetSpawned(calls.spawns[0]?.req ?? "", "s9"); + expect(signal().opened).toEqual(["s9"]); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); + + it("keeps the New form when the spawn fails", () => { + sendable = false; + shell.showSessions(); + query("#session-new").click(); + const project = query("#new-project"); + project.value = "demo"; + project.dispatchEvent(new Event("change", { bubbles: true })); + query("#new-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(query("#new-error").textContent).toBe("Not connected"); + expect(root.querySelector("#new-form")).not.toBeNull(); + expect(query("#new-project").value).toBe("demo"); + }); + + it("shows a spawn error beside the project field", () => { + shell.showSessions(); + query("#session-new").click(); + const project = query("#new-project"); + project.value = "demo"; + project.dispatchEvent(new Event("change", { bubbles: true })); + query("#new-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + shell.sheetError("unknown project: demo", calls.spawns[0]?.req ?? "", "unknown_project"); + const error = query("#new-project-error"); + expect(error.hidden).toBe(false); + expect(error.textContent).toBe("unknown project: demo"); + }); + + it("previews and sends a draft from the Builder", () => { + shell.showSession("s1", "build"); + query(".composer-builder").click(); + expect(root.querySelector("#builder-form")).not.toBeNull(); + query("#builder-preview").click(); + expect(calls.composes).toHaveLength(1); + expect(calls.composes[0]?.draft).toEqual({ sections: [], skills: [] }); + shell.sheetComposed(calls.composes[0]?.req ?? "", "composed text"); + expect(query("#builder-composed").textContent).toBe("composed text"); + expect((query("#builder-composed") as HTMLElement).hidden).toBe(false); + query("#builder-send").click(); + expect(calls.draftSends).toHaveLength(1); + expect(calls.draftSends[0]?.draft).toEqual({ sections: [], skills: [] }); + shell.sheetOk(calls.draftSends[0]?.req ?? ""); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); + + it("closes an open sheet on a route change", () => { + shell.showSessions(); + query("#session-new").click(); + expect(root.querySelector(".sheet-overlay")).not.toBeNull(); + shell.showSession("s1", "build"); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + + query(".composer-builder").click(); + expect(root.querySelector("#builder-form")).not.toBeNull(); + shell.showSessions(); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); }); diff --git a/apps/remote/web/test/builder.test.ts b/apps/remote/web/test/builder.test.ts new file mode 100644 index 0000000..82e20c1 --- /dev/null +++ b/apps/remote/web/test/builder.test.ts @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Builder } from "../src/builder.js"; +import { CatalogStore } from "../src/catalog.js"; +import { DRAFT_STORAGE_KEY, saveDraft } from "../src/draft.js"; +import type { PromptDraft } from "../src/protocol.js"; +import type { Catalog } from "../src/protocol.js"; +import { FakeStorage } from "./fakes.js"; + +const CATALOG: Catalog = { + clis: [{ cli: "muse", label: "Muse", models: ["spark"] }], + projects: [ + { name: "demo", path: "/home/u/demo" }, + { name: "other", path: "/home/u/other" }, + ], + presets: [ + { name: "review", preview: "Review the diff." }, + { name: "plan", preview: "Plan it first." }, + ], + skills: [ + { name: "tdd", label: "TDD" }, + { name: "docs", label: "Docs" }, + ], +}; + +const STORED: PromptDraft = { + sections: [ + { title: "Goal", content: "ship it" }, + { title: "Notes", content: "" }, + ], + preset: "review", + skills: ["tdd"], + project: "demo", +}; + +describe("Builder", () => { + let root: HTMLDivElement; + let catalog: CatalogStore; + let builder: Builder; + let storage: FakeStorage; + let composed: { draft: PromptDraft; req: string }[]; + let sends: { draft: PromptDraft; req: string }[]; + let connected: boolean; + + const query = (selector: string): T => { + const el = root.querySelector(selector); + if (!el) { + throw new Error(`missing ${selector}`); + } + return el as T; + }; + + const open = (): void => { + builder.open(root); + }; + + const setValue = (selector: string, value: string): void => { + const el = query(selector); + el.value = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const select = (selector: string, value: string): void => { + const el = query(selector); + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + + const toggle = (name: string): void => { + const box = [...root.querySelectorAll(".builder-skill")].find( + (input) => input.value === name, + ); + if (!box) { + throw new Error(`missing skill ${name}`); + } + box.checked = !box.checked; + box.dispatchEvent(new Event("change", { bubbles: true })); + }; + + const titles = (): string[] => + [...root.querySelectorAll(".section-title")].map((input) => input.value); + + const stored = (): PromptDraft | null => { + const raw = storage.getItem(DRAFT_STORAGE_KEY); + return raw === null ? null : (JSON.parse(raw) as PromptDraft); + }; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + catalog = new CatalogStore(); + catalog.replace(CATALOG); + storage = new FakeStorage(); + composed = []; + sends = []; + connected = true; + builder = new Builder( + catalog, + { + onCompose: (draft, req) => { + composed.push({ draft, req }); + return connected; + }, + onSend: (draft, req) => { + sends.push({ draft, req }); + return connected; + }, + }, + storage, + ); + }); + + afterEach(() => { + builder.close(); + root.remove(); + document.body.innerHTML = ""; + }); + + it("opens with one empty section", () => { + open(); + expect(builder.isOpen).toBe(true); + expect(root.querySelectorAll(".builder-section")).toHaveLength(1); + expect(titles()).toEqual([""]); + expect(query("#builder-preset").value).toBe(""); + expect(query("#builder-project").value).toBe(""); + }); + + it("loads the saved draft, including preset preview and skills", () => { + saveDraft(STORED, storage); + open(); + expect(titles()).toEqual(["Goal", "Notes"]); + expect(query("#builder-preset").value).toBe("review"); + expect(query("#builder-preset-preview").textContent).toBe("Review the diff."); + expect(query("#builder-project").value).toBe("demo"); + const skills = [...root.querySelectorAll(".builder-skill")]; + expect(skills.map((box) => box.value)).toEqual(["tdd", "docs"]); + expect(skills.map((box) => box.checked)).toEqual([true, false]); + }); + + it("adds, moves and removes sections", () => { + open(); + setValue(".section-title", "A"); + query("#builder-add-section").click(); + expect(root.querySelectorAll(".builder-section")).toHaveLength(2); + setValue('.section-title[data-index="1"]', "B"); + expect(titles()).toEqual(["A", "B"]); + + query('.section-down[data-index="0"]').click(); + expect(titles()).toEqual(["B", "A"]); + expect(stored()?.sections.map((section) => section.title)).toEqual(["B", "A"]); + + query('.section-up[data-index="1"]').click(); + expect(titles()).toEqual(["A", "B"]); + + query('.section-remove[data-index="0"]').click(); + expect(titles()).toEqual(["B"]); + expect(stored()?.sections.map((section) => section.title)).toEqual(["B"]); + }); + + it("composes the wire draft and shows the text read-only", () => { + open(); + setValue(".section-title", "Goal"); + setValue(".section-content", "ship it"); + query("#builder-add-section").click(); + setValue('.section-title[data-index="1"]', " "); + select("#builder-preset", "review"); + select("#builder-project", "demo"); + toggle("tdd"); + + query("#builder-preview").click(); + expect(composed).toHaveLength(1); + expect(composed[0]?.draft).toEqual({ + sections: [{ title: "Goal", content: "ship it" }], + preset: "review", + skills: ["tdd"], + project: "demo", + }); + + const req = composed[0]?.req ?? ""; + expect(builder.handleComposed("other", "nope")).toBe(false); + expect(builder.handleComposed(req, "# Goal\n\nship it")).toBe(true); + const pre = query("#builder-composed"); + expect(pre.tagName).toBe("PRE"); + expect(pre.textContent).toBe("# Goal\n\nship it"); + expect(pre.hidden).toBe(false); + }); + + it("drops a stale preview when the draft changes", () => { + open(); + query("#builder-preview").click(); + builder.handleComposed(composed[0]?.req ?? "", "old text"); + expect(query("#builder-composed").hidden).toBe(false); + setValue(".section-title", "new"); + expect(query("#builder-composed").hidden).toBe(true); + expect(query("#builder-composed").textContent).toBe(""); + // A compose reply that lands after the edit is ignored. + expect(builder.handleComposed(composed[0]?.req ?? "", "late text")).toBe(false); + expect(query("#builder-composed").hidden).toBe(true); + }); + + it("sends the draft to the open session and clears it on the matching ok", () => { + open(); + setValue(".section-title", "Goal"); + setValue(".section-content", "ship it"); + select("#builder-project", "demo"); + toggle("docs"); + query("#builder-send").click(); + expect(sends).toHaveLength(1); + expect(sends[0]?.draft).toEqual({ + sections: [{ title: "Goal", content: "ship it" }], + skills: ["docs"], + project: "demo", + }); + const req = sends[0]?.req ?? ""; + expect(builder.handleOk("other")).toBe(false); + expect(builder.isOpen).toBe(true); + expect(builder.handleOk(req)).toBe(true); + expect(builder.isOpen).toBe(false); + expect(storage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + + // A new draft starts over. + open(); + expect(titles()).toEqual([""]); + const skills = [...root.querySelectorAll(".builder-skill")]; + expect(skills.every((box) => !box.checked)).toBe(true); + expect(query("#builder-preset").value).toBe(""); + expect(query("#builder-project").value).toBe(""); + }); + + it("keeps the draft across closing and reopening", () => { + open(); + setValue(".section-title", "Goal"); + setValue(".section-content", "keep me"); + select("#builder-preset", "plan"); + expect(stored()?.preset).toBe("plan"); + query("#builder-close").click(); + expect(builder.isOpen).toBe(false); + expect(storage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(); + + open(); + expect(titles()).toEqual(["Goal"]); + expect(query(".section-content").value).toBe("keep me"); + expect(query("#builder-preset").value).toBe("plan"); + expect(query("#builder-preset-preview").textContent).toBe("Plan it first."); + }); + + it("keeps the draft in memory even when storage denies writes", () => { + storage.failWrites = true; + open(); + setValue(".section-title", "Goal"); + query("#builder-close").click(); + open(); + expect(titles()).toEqual(["Goal"]); + expect(storage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + }); + + it("shows skill names the catalog still lists plus draft extras", () => { + saveDraft({ sections: [], skills: ["tdd", "legacy"] }, storage); + open(); + const boxes = [...root.querySelectorAll(".builder-skill")]; + expect(boxes.map((box) => box.value)).toEqual(["tdd", "docs", "legacy"]); + expect(boxes.map((box) => box.checked)).toEqual([true, false, true]); + toggle("legacy"); + expect(stored()?.skills).toEqual(["tdd"]); + }); + + it("shows unknown preset, skill and project errors beside their fields", () => { + open(); + query("#builder-preview").click(); + expect(builder.handleError("unknown preset: fast", composed[0]?.req ?? "", "unknown_preset")).toBe( + true, + ); + expect(query("#builder-preset-error").hidden).toBe(false); + expect(query("#builder-preset-error").textContent).toBe("unknown preset: fast"); + + query("#builder-send").click(); + builder.handleError("unknown skill: tdd", sends[0]?.req ?? "", "unknown_skill"); + expect(query("#builder-skills-error").hidden).toBe(false); + expect(query("#builder-skills-error").textContent).toBe("unknown skill: tdd"); + + query("#builder-send").click(); + builder.handleError("unknown project demo", sends[1]?.req ?? "", "unknown_project"); + expect(query("#builder-project-error").hidden).toBe(false); + + query("#builder-send").click(); + builder.handleError("internal trouble", sends[2]?.req ?? "", "internal"); + expect(query("#builder-error").hidden).toBe(false); + expect(query("#builder-error").textContent).toBe("internal trouble"); + + // A stale reply is ignored. + expect(builder.handleError("late", "no-such-req", "internal")).toBe(false); + // The form keeps its draft on error. + expect(builder.isOpen).toBe(true); + expect(titles()).toEqual([""]); + }); + + it("says so when the sheet is not connected", () => { + connected = false; + open(); + query("#builder-preview").click(); + expect(query("#builder-error").textContent).toBe("Not connected"); + query("#builder-send").click(); + expect(query("#builder-error").textContent).toBe("Not connected"); + expect(composed).toHaveLength(1); + expect(sends).toHaveLength(1); + }); + + it("shows the preset preview when the preset changes", () => { + open(); + expect(query("#builder-preset-preview").textContent).toBe(""); + select("#builder-preset", "review"); + expect(query("#builder-preset-preview").textContent).toBe("Review the diff."); + select("#builder-preset", ""); + expect(query("#builder-preset-preview").textContent).toBe(""); + }); +}); diff --git a/apps/remote/web/test/catalog.test.ts b/apps/remote/web/test/catalog.test.ts new file mode 100644 index 0000000..bc281dc --- /dev/null +++ b/apps/remote/web/test/catalog.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { CatalogStore, emptyCatalog } from "../src/catalog.js"; +import type { Catalog } from "../src/protocol.js"; + +const CATALOG: Catalog = { + clis: [{ cli: "muse", label: "Muse", models: ["spark"] }], + projects: [{ name: "demo", path: "/home/u/demo" }], + presets: [{ name: "review", preview: "look closely" }], + skills: [{ name: "tdd", label: "TDD" }], +}; + +describe("CatalogStore", () => { + it("starts empty and unloaded", () => { + const store = new CatalogStore(); + expect(store.isLoaded).toBe(false); + expect(store.snapshot()).toEqual(emptyCatalog()); + expect(store.clis).toEqual([]); + expect(store.projects).toEqual([]); + expect(store.presets).toEqual([]); + expect(store.skills).toEqual([]); + }); + + it("caches the catalog and notifies subscribers", () => { + const store = new CatalogStore(); + const listener = vi.fn(); + const stop = store.subscribe(listener); + store.replace(CATALOG); + expect(store.isLoaded).toBe(true); + expect(store.clis).toEqual(CATALOG.clis); + expect(store.projects).toEqual(CATALOG.projects); + expect(store.presets).toEqual(CATALOG.presets); + expect(store.skills).toEqual(CATALOG.skills); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + store.replace(CATALOG); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("clears on a new or lost host", () => { + const store = new CatalogStore(); + const listener = vi.fn(); + store.subscribe(listener); + store.replace(CATALOG); + store.clear(); + expect(store.isLoaded).toBe(false); + expect(store.snapshot()).toEqual(emptyCatalog()); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("finds catalog entries by key", () => { + const store = new CatalogStore(); + store.replace(CATALOG); + expect(store.findCli("muse")?.label).toBe("Muse"); + expect(store.findCli("nope")).toBeNull(); + expect(store.findProject("demo")?.path).toBe("/home/u/demo"); + expect(store.findProject("nope")).toBeNull(); + expect(store.findPreset("review")?.preview).toBe("look closely"); + expect(store.findPreset("nope")).toBeNull(); + }); +}); diff --git a/apps/remote/web/test/draft.test.ts b/apps/remote/web/test/draft.test.ts new file mode 100644 index 0000000..2190383 --- /dev/null +++ b/apps/remote/web/test/draft.test.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { + DRAFT_STORAGE_KEY, + addSection, + classifyDraftError, + clearDraft, + emptyDraft, + isBlankSection, + loadDraft, + moveSection, + parseStoredDraft, + removeSection, + saveDraft, + setPreset, + setProject, + setSkill, + toggleSkill, + updateSection, + wireDraft, +} from "../src/draft.js"; +import type { PromptDraft } from "../src/protocol.js"; +import { FakeStorage } from "./fakes.js"; + +const DRAFT: PromptDraft = { + sections: [ + { title: "Goal", content: "ship it" }, + { title: "Constraints", content: "no deps" }, + { title: "Notes", content: "keep it small" }, + ], + preset: "review", + skills: ["tdd", "docs"], + project: "demo", +}; + +describe("draft operations", () => { + it("starts empty", () => { + expect(emptyDraft()).toEqual({ sections: [], skills: [] }); + }); + + it("adds sections without mutating the draft", () => { + const before = emptyDraft(); + const after = addSection(before, { title: "Goal" }); + expect(after.sections).toEqual([{ title: "Goal", content: "" }]); + expect(before.sections).toEqual([]); + expect(addSection(after).sections[1]).toEqual({ title: "", content: "" }); + }); + + it("removes by index and ignores out-of-range indexes", () => { + expect(removeSection(DRAFT, 1).sections.map((section) => section.title)).toEqual([ + "Goal", + "Notes", + ]); + expect(removeSection(DRAFT, 9)).toBe(DRAFT); + expect(removeSection(DRAFT, -1)).toBe(DRAFT); + }); + + it("moves sections up and down, clamped", () => { + const up = moveSection(DRAFT, 1, -1); + expect(up.sections.map((section) => section.title)).toEqual(["Constraints", "Goal", "Notes"]); + const down = moveSection(DRAFT, 1, 1); + expect(down.sections.map((section) => section.title)).toEqual(["Goal", "Notes", "Constraints"]); + expect(moveSection(DRAFT, 0, -1)).toBe(DRAFT); + expect(moveSection(DRAFT, 2, 1)).toBe(DRAFT); + // The original is untouched. + expect(DRAFT.sections.map((section) => section.title)).toEqual(["Goal", "Constraints", "Notes"]); + }); + + it("updates one section field", () => { + const next = updateSection(DRAFT, 0, { content: "ship it today" }); + expect(next.sections[0]).toEqual({ title: "Goal", content: "ship it today" }); + expect(next.sections[1]).toBe(DRAFT.sections[1]); + expect(updateSection(DRAFT, 5, { title: "x" })).toBe(DRAFT); + }); + + it("sets and clears the preset and project", () => { + expect(setPreset(DRAFT, null)).not.toHaveProperty("preset"); + expect(setPreset(DRAFT, "")).not.toHaveProperty("preset"); + expect(setPreset(DRAFT, "other").preset).toBe("other"); + expect(setProject(DRAFT, null)).not.toHaveProperty("project"); + expect(setProject(DRAFT, "other").project).toBe("other"); + expect(DRAFT.preset).toBe("review"); + }); + + it("adds, removes and dedupes skills", () => { + expect(setSkill(DRAFT, "tdd", false).skills).toEqual(["docs"]); + expect(setSkill(DRAFT, "extra", true).skills).toEqual(["tdd", "docs", "extra"]); + expect(setSkill(DRAFT, "tdd", true)).toBe(DRAFT); + expect(toggleSkill(DRAFT, "docs").skills).toEqual(["tdd"]); + expect(toggleSkill(DRAFT, "extra").skills).toEqual(["tdd", "docs", "extra"]); + expect(setSkill(DRAFT, "", true)).toBe(DRAFT); + }); +}); + +describe("wireDraft", () => { + it("drops fully empty sections and always sends a skills array", () => { + const draft: PromptDraft = { + sections: [ + { title: "Goal", content: "ship it" }, + { title: "", content: " " }, + { title: " Notes ", content: "" }, + ], + skills: [], + }; + expect(wireDraft(draft)).toEqual({ + sections: [ + { title: "Goal", content: "ship it" }, + { title: " Notes ", content: "" }, + ], + skills: [], + }); + }); + + it("omits empty preset and project and dedupes skills", () => { + const draft: PromptDraft = { + sections: [], + preset: "", + skills: ["tdd", "tdd", ""], + project: "", + }; + expect(wireDraft(draft)).toEqual({ sections: [], skills: ["tdd"] }); + expect(wireDraft(DRAFT)).toEqual({ + sections: DRAFT.sections, + preset: "review", + skills: ["tdd", "docs"], + project: "demo", + }); + }); + + it("recognizes blank sections", () => { + expect(isBlankSection({ title: " ", content: "\n" })).toBe(true); + expect(isBlankSection({ title: "x", content: "" })).toBe(false); + }); +}); + +describe("classifyDraftError", () => { + it("maps the host's unknown codes to fields", () => { + expect(classifyDraftError("unknown_preset", "no such preset")).toEqual({ + field: "preset", + message: "no such preset", + }); + expect(classifyDraftError("unknown_skill", "no such skill").field).toBe("skills"); + expect(classifyDraftError("unknown_project", "no such project").field).toBe("project"); + }); + + it("falls back to the message's wording", () => { + expect(classifyDraftError("bad_request", "unknown preset: fast").field).toBe("preset"); + expect(classifyDraftError("not_found", "Unknown skill \"tdd\"").field).toBe("skills"); + expect(classifyDraftError("not_found", "project demo was not found").field).toBe("project"); + expect(classifyDraftError("not_found", "unknown workdir /nope").field).toBe("project"); + }); + + it("keeps unrelated errors general", () => { + expect(classifyDraftError("internal", "boom")).toEqual({ field: null, message: "boom" }); + expect(classifyDraftError("bad_request", "unknown frame type").field).toBeNull(); + }); +}); + +describe("draft persistence", () => { + it("round-trips through a sessionStorage stand-in", () => { + const storage = new FakeStorage(); + saveDraft(DRAFT, storage); + expect(storage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(); + expect(loadDraft(storage)).toEqual(DRAFT); + clearDraft(storage); + expect(storage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + expect(loadDraft(storage)).toEqual(emptyDraft()); + }); + + it("survives malformed or partial JSON", () => { + expect(parseStoredDraft(null)).toBeNull(); + expect(parseStoredDraft("")).toBeNull(); + expect(parseStoredDraft("{not json")).toBeNull(); + expect(parseStoredDraft("[]")).toBeNull(); + expect(parseStoredDraft(JSON.stringify({ sections: [{ title: "ok", content: "" }, null, 7] }))).toEqual({ + sections: [{ title: "ok", content: "" }], + skills: [], + }); + expect( + parseStoredDraft(JSON.stringify({ sections: [], skills: ["tdd", 4], preset: "", project: 3 })), + ).toEqual({ sections: [], skills: ["tdd"] }); + }); + + it("tolerates a browser that denies storage", () => { + const storage = new FakeStorage(); + saveDraft(DRAFT, storage); + storage.failReads = true; + storage.failWrites = true; + expect(loadDraft(storage)).toEqual(emptyDraft()); + expect(() => saveDraft(DRAFT, storage)).not.toThrow(); + expect(() => clearDraft(storage)).not.toThrow(); + }); + + it("does nothing without a storage", () => { + expect(loadDraft(null)).toEqual(emptyDraft()); + expect(() => saveDraft(DRAFT, null)).not.toThrow(); + expect(() => clearDraft(null)).not.toThrow(); + }); +}); diff --git a/apps/remote/web/test/fakes.ts b/apps/remote/web/test/fakes.ts index bbf5da7..92bb5ac 100644 --- a/apps/remote/web/test/fakes.ts +++ b/apps/remote/web/test/fakes.ts @@ -279,6 +279,47 @@ export interface VisibilityHarness { setVisible: (visible: boolean) => void; } +// sessionStorage stand-in for the Prompt Builder draft: in memory, with +// switches for the browsers that throw on read or write. +export class FakeStorage { + readonly map = new Map(); + failReads = false; + failWrites = false; + + getItem(key: string): string | null { + if (this.failReads) { + throw new Error("storage read denied"); + } + return this.map.get(key) ?? null; + } + + setItem(key: string, value: string): void { + if (this.failWrites) { + throw new Error("storage write denied"); + } + this.map.set(key, value); + } + + removeItem(key: string): void { + if (this.failWrites) { + throw new Error("storage write denied"); + } + this.map.delete(key); + } + + get length(): number { + return this.map.size; + } + + key(index: number): string | null { + return [...this.map.keys()][index] ?? null; + } + + clear(): void { + this.map.clear(); + } +} + export function createVisibilityHarness(initial = true): VisibilityHarness { let visible = initial; const listeners = new Set<() => void>(); diff --git a/apps/remote/web/test/new-dialog.test.ts b/apps/remote/web/test/new-dialog.test.ts new file mode 100644 index 0000000..721e061 --- /dev/null +++ b/apps/remote/web/test/new-dialog.test.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CatalogStore } from "../src/catalog.js"; +import { NewDialog } from "../src/new-dialog.js"; +import type { SpawnRequest } from "../src/protocol.js"; +import type { Catalog } from "../src/protocol.js"; + +const CATALOG: Catalog = { + clis: [ + { cli: "muse", label: "Muse", models: ["spark", "core"] }, + { cli: "claude", label: "Claude", models: [] }, + ], + projects: [ + { name: "demo", path: "/home/u/demo" }, + { name: "other", path: "/home/u/other" }, + ], + presets: [], + skills: [], +}; + +describe("NewDialog", () => { + let root: HTMLDivElement; + let catalog: CatalogStore; + let dialog: NewDialog; + let spawns: { spawn: SpawnRequest; req: string }[]; + let connected: boolean; + + const query = (selector: string): T => { + const el = root.querySelector(selector); + if (!el) { + throw new Error(`missing ${selector}`); + } + return el as T; + }; + + const submit = (): void => { + query("#new-form").dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }; + + const change = (selector: string, value: string): void => { + const el = query(selector); + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + catalog = new CatalogStore(); + catalog.replace(CATALOG); + spawns = []; + connected = true; + dialog = new NewDialog(catalog, { + onSpawn: (spawn, req) => { + spawns.push({ spawn, req }); + return connected; + }, + }); + dialog.open(root); + }); + + afterEach(() => { + dialog.close(); + root.remove(); + document.body.innerHTML = ""; + }); + + it("defaults to the first harness with its models and no project", () => { + expect(query("#new-cli").value).toBe("muse"); + const models = [...root.querySelectorAll("#new-model option")]; + expect(models.map((option) => option.value)).toEqual(["", "spark", "core"]); + expect(query("#new-model").value).toBe(""); + expect(query("#new-project").value).toBe(""); + expect(query("#new-project option").textContent).toBe("Choose a project…"); + }); + + it("preselects the only project and follows a harness change", () => { + catalog.replace({ ...CATALOG, projects: [CATALOG.projects[0] as Catalog["projects"][number]] }); + expect(query("#new-project").value).toBe("demo"); + change("#new-cli", "claude"); + expect(query("#new-cli").value).toBe("claude"); + const models = [...root.querySelectorAll("#new-model option")]; + expect(models.map((option) => option.value)).toEqual([""]); + expect(query("#new-model").value).toBe(""); + }); + + it("sends the exact spawn frame and omits empty optional fields", () => { + change("#new-cli", "claude"); + change("#new-project", "demo"); + query("#new-name").value = " build "; + query("#new-prompt").value = " do the thing \n"; + submit(); + expect(spawns).toHaveLength(1); + expect(spawns[0]?.spawn).toEqual({ + cli: "claude", + workdir: "/home/u/demo", + name: "build", + prompt: "do the thing", + }); + expect(typeof spawns[0]?.req).toBe("string"); + expect(dialog.isOpen).toBe(true); + }); + + it("includes a chosen model", () => { + change("#new-project", "other"); + change("#new-model", "spark"); + submit(); + expect(spawns[0]?.spawn).toEqual({ + cli: "muse", + model: "spark", + workdir: "/home/u/other", + }); + }); + + it("requires a project", () => { + submit(); + expect(spawns).toHaveLength(0); + const error = query("#new-project-error"); + expect(error.hidden).toBe(false); + expect(error.textContent).toBe("Choose a project"); + }); + + it("closes on a matching spawned and ignores other reqs", () => { + change("#new-project", "demo"); + submit(); + const req = spawns[0]?.req ?? ""; + expect(dialog.handleSpawned("other")).toBe(false); + expect(dialog.isOpen).toBe(true); + expect(dialog.handleSpawned(req)).toBe(true); + expect(dialog.isOpen).toBe(false); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); + + it("shows a spawn error beside the project field and keeps the form", () => { + change("#new-project", "demo"); + query("#new-name").value = "build"; + submit(); + const req = spawns[0]?.req ?? ""; + expect(dialog.handleError("unknown project: demo", req, "unknown_project")).toBe(true); + const error = query("#new-project-error"); + expect(error.hidden).toBe(false); + expect(error.textContent).toBe("unknown project: demo"); + expect(dialog.isOpen).toBe(true); + expect(query("#new-name").value).toBe("build"); + expect(query("#new-project").value).toBe("demo"); + }); + + it("shows other errors above the actions", () => { + change("#new-project", "demo"); + submit(); + const req = spawns[0]?.req ?? ""; + expect(dialog.handleError("spawn_failed: harness exited", req, "spawn_failed")).toBe(true); + expect(query("#new-error").textContent).toBe("spawn_failed: harness exited"); + expect(dialog.handleError("late", req, "internal")).toBe(false); + }); + + it("says so when the frame cannot be sent", () => { + connected = false; + change("#new-project", "demo"); + submit(); + expect(query("#new-error").textContent).toBe("Not connected"); + expect(dialog.isOpen).toBe(true); + }); + + it("fills the pickers when the catalog arrives late", () => { + catalog.clear(); + expect(query("#new-cli option").textContent).toBe("No harnesses"); + catalog.replace(CATALOG); + expect(query("#new-cli").value).toBe("muse"); + expect([...root.querySelectorAll("#new-project option")]).toHaveLength(3); + }); + + it("closes from the close button", () => { + query("#new-close").click(); + expect(dialog.isOpen).toBe(false); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); +}); diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts index c0d05eb..1fa68bc 100644 --- a/apps/remote/web/test/protocol.test.ts +++ b/apps/remote/web/test/protocol.test.ts @@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest"; import { b64encode } from "../src/crypto.js"; import { buildAttach, + buildCatalog, + buildCompose, buildDetach, buildInput, buildInputText, buildList, buildPrompt, + buildPromptDraft, + buildSpawn, decodeSnapshotData, decodeUtf8, formatLastOutput, newReqId, + parseCatalog, + parseComposed, parseError, parseExited, parseOk, @@ -20,6 +26,7 @@ import { parseSessionItem, parseSessionRemoved, parseSessions, + parseSpawned, statusDot, workdirBasename, } from "../src/protocol.js"; @@ -27,6 +34,7 @@ import { describe("builders", () => { it("builds the exact session frames", () => { expect(JSON.parse(buildList("r1"))).toEqual({ t: "list", req: "r1" }); + expect(JSON.parse(buildCatalog("r1"))).toEqual({ t: "catalog", req: "r1" }); expect(JSON.parse(buildAttach("s1"))).toEqual({ t: "attach", id: "s1" }); expect(JSON.parse(buildDetach("s1"))).toEqual({ t: "detach", id: "s1" }); expect(JSON.parse(buildPrompt("r2", "s1", "hello"))).toEqual({ @@ -37,6 +45,40 @@ describe("builders", () => { }); }); + it("builds the exact spawn, compose and draft frames", () => { + expect( + JSON.parse(buildSpawn("r3", { cli: "muse", workdir: "/home/u/demo" })), + ).toEqual({ t: "spawn", req: "r3", kind: "agent", cli: "muse", workdir: "/home/u/demo" }); + expect( + JSON.parse( + buildSpawn("r4", { + cli: "muse", + model: "spark", + workdir: "/home/u/demo", + name: "build", + prompt: "do it", + }), + ), + ).toEqual({ + t: "spawn", + req: "r4", + kind: "agent", + cli: "muse", + model: "spark", + workdir: "/home/u/demo", + name: "build", + prompt: "do it", + }); + const draft = { sections: [{ title: "Goal", content: "ship it" }], skills: ["tdd"] }; + expect(JSON.parse(buildCompose("r5", draft))).toEqual({ t: "compose", req: "r5", draft }); + expect(JSON.parse(buildPromptDraft("r6", "s1", draft))).toEqual({ + t: "prompt", + req: "r6", + id: "s1", + draft, + }); + }); + it("base64-encodes input bytes", () => { expect(JSON.parse(buildInput("s1", new Uint8Array([0x1b, 0x5b, 0x41])))).toEqual({ t: "input", @@ -138,6 +180,58 @@ describe("frame parsers", () => { expect(parseError({ t: "error", code: "internal" })).toBeNull(); }); + it("parses spawned and composed", () => { + expect(parseSpawned({ t: "spawned", req: "r1", id: "s9" })).toEqual({ req: "r1", id: "s9" }); + expect(parseSpawned({ t: "spawned", req: "r1" })).toBeNull(); + expect(parseSpawned({ t: "spawned", req: "r1", id: "" })).toBeNull(); + expect(parseComposed({ t: "composed", req: "r2", text: "hello" })).toEqual({ + req: "r2", + text: "hello", + }); + expect(parseComposed({ t: "composed", req: "r2" })).toBeNull(); + expect(parseComposed({ t: "ok", req: "r2" })).toBeNull(); + }); + + it("parses the catalog and drops malformed entries", () => { + expect( + parseCatalog({ + t: "catalog", + req: "r1", + clis: [ + { cli: "muse", label: "Muse", models: ["spark", 7] }, + { cli: "", label: "broken" }, + { label: "no cli" }, + { cli: "claude" }, + ], + projects: [ + { name: "demo", path: "/home/u/demo" }, + { name: "no path" }, + { name: "empty path", path: "" }, + ], + presets: [{ name: "review", preview: "look" }, { name: "" }, { preview: "orphan" }], + skills: [{ name: "tdd", label: "TDD" }, { name: "docs" }], + }), + ).toEqual({ + clis: [ + { cli: "muse", label: "Muse", models: ["spark"] }, + { cli: "claude", label: "claude", models: [] }, + ], + projects: [{ name: "demo", path: "/home/u/demo" }], + presets: [{ name: "review", preview: "look" }], + skills: [ + { name: "tdd", label: "TDD" }, + { name: "docs", label: "docs" }, + ], + }); + expect(parseCatalog({ t: "catalog", req: "r1" })).toEqual({ + clis: [], + projects: [], + presets: [], + skills: [], + }); + expect(parseCatalog({ t: "sessions", items: [] })).toBeNull(); + }); + it("decodes snapshot data as UTF-8", () => { const bytes = decodeSnapshotData(b64encode(new TextEncoder().encode("héllo"))); expect(bytes).not.toBeNull(); diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts index a3bbaa0..6eb449d 100644 --- a/apps/remote/web/test/router.test.ts +++ b/apps/remote/web/test/router.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { Shell } from "../src/app.js"; +import { CatalogStore } from "../src/catalog.js"; import { createChannelHandler } from "../src/channel.js"; import { b64encode } from "../src/crypto.js"; import { createFrameRouter } from "../src/router.js"; @@ -17,23 +18,33 @@ const b64 = (text: string): string => b64encode(new TextEncoder().encode(text)); interface Harness { router: FrameRouter; store: SessionStore; + catalog: CatalogStore; controller: SessionController; written: string[]; terminals: { id: string; cols: number; rows: number }[]; oks: string[]; errors: { message: string; req: string | null }[]; exits: number[]; + spawned: { req: string; id: string }[]; + composed: { req: string; text: string }[]; + sheetOks: string[]; + sheetErrors: { message: string; req: string | null; code: string }[]; hostSend: (frame: unknown) => void; } function setup(): Harness { const channel = new FakeDataChannel("canopy-session-v1", { ordered: true }); const store = new SessionStore(); + const catalog = new CatalogStore(); const written: string[] = []; const terminals: Harness["terminals"] = []; const oks: string[] = []; const errors: Harness["errors"] = []; const exits: number[] = []; + const spawned: Harness["spawned"] = []; + const composed: Harness["composed"] = []; + const sheetOks: string[] = []; + const sheetErrors: Harness["sheetErrors"] = []; // Only the methods the router calls; main.ts wires the real shell. const shell = { ensureTerminal: (id: string, cols: number, rows: number) => { @@ -48,6 +59,18 @@ function setup(): Harness { showSessionExited: (code: number) => { exits.push(code); }, + sheetSpawned: (req: string, id: string) => { + spawned.push({ req, id }); + }, + sheetComposed: (req: string, text: string) => { + composed.push({ req, text }); + }, + sheetOk: (req: string) => { + sheetOks.push(req); + }, + sheetError: (message: string, req: string | null, code: string) => { + sheetErrors.push({ message, req, code }); + }, } as unknown as Shell; const controller = new SessionController({ send: (data) => { @@ -60,7 +83,7 @@ function setup(): Harness { }), onExited: (_id, code) => shell.showSessionExited(code), }); - const router = createFrameRouter({ store, controller, shell }); + const router = createFrameRouter({ store, controller, shell, catalog }); const frames = createChannelHandler({ onSnapshot: router.handleSnapshot, onFrame: router.handleFrame, @@ -73,12 +96,17 @@ function setup(): Harness { return { router, store, + catalog, controller, written, terminals, oks, errors, exits, + spawned, + composed, + sheetOks, + sheetErrors, hostSend: (frame: unknown) => channel.deliver(JSON.stringify(frame)), }; } @@ -118,12 +146,38 @@ describe("createFrameRouter", () => { expect(h.written.join("")).toBe("hello!"); }); - it("routes ok and error replies to the composer", () => { + it("routes ok and error replies to the composer and the sheets", () => { const h = setup(); h.hostSend({ t: "ok", req: "r9" }); expect(h.oks).toEqual(["r9"]); + expect(h.sheetOks).toEqual(["r9"]); h.hostSend({ t: "error", req: "r10", code: "delivery_failed", message: "host is gone" }); expect(h.errors).toEqual([{ message: "host is gone", req: "r10" }]); + expect(h.sheetErrors).toEqual([ + { message: "host is gone", req: "r10", code: "delivery_failed" }, + ]); + }); + + it("caches the catalog and routes spawned and composed to the shell", () => { + const h = setup(); + h.hostSend({ + t: "catalog", + req: "r1", + clis: [{ cli: "muse", label: "Muse", models: ["spark"] }], + projects: [{ name: "demo", path: "/home/u/demo" }], + presets: [{ name: "review", preview: "look" }], + skills: [{ name: "tdd", label: "TDD" }], + }); + expect(h.catalog.isLoaded).toBe(true); + expect(h.catalog.clis.map((cli) => cli.cli)).toEqual(["muse"]); + expect(h.catalog.projects.map((project) => project.name)).toEqual(["demo"]); + expect(h.catalog.presets.map((preset) => preset.name)).toEqual(["review"]); + expect(h.catalog.skills.map((skill) => skill.name)).toEqual(["tdd"]); + + h.hostSend({ t: "spawned", req: "r2", id: "s9" }); + expect(h.spawned).toEqual([{ req: "r2", id: "s9" }]); + h.hostSend({ t: "composed", req: "r3", text: "# Goal" }); + expect(h.composed).toEqual([{ req: "r3", text: "# Goal" }]); }); it("ignores unknown frames without throwing", () => { @@ -136,5 +190,7 @@ describe("createFrameRouter", () => { expect(h.store.size).toBe(0); expect(h.oks).toEqual([]); expect(h.errors).toEqual([]); + expect(h.spawned).toEqual([]); + expect(h.composed).toEqual([]); }); }); diff --git a/apps/remote/web/test/send-draft.test.ts b/apps/remote/web/test/send-draft.test.ts new file mode 100644 index 0000000..28d3a4a --- /dev/null +++ b/apps/remote/web/test/send-draft.test.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// The Prompt Builder's send path end to end: the shell's Send ships +// prompt{id,draft} through the controller and the host's ok closes the +// sheet, so the exact frame and the reply matching share one test. + +import { afterEach, describe, expect, it } from "vitest"; +import { createShell } from "../src/app.js"; +import type { Shell } from "../src/app.js"; +import { CatalogStore } from "../src/catalog.js"; +import { createChannelHandler } from "../src/channel.js"; +import type { PromptDraft } from "../src/protocol.js"; +import { createFrameRouter } from "../src/router.js"; +import { SessionController } from "../src/session.js"; +import { SessionStore } from "../src/sessions.js"; +import { FakeDataChannel } from "./fakes.js"; + +const DRAFT: PromptDraft = { + sections: [{ title: "Goal", content: "ship it" }], + preset: "review", + skills: ["tdd"], + project: "demo", +}; + +describe("SessionController.sendPromptDraft", () => { + it("refuses without an open session", () => { + const sent: string[] = []; + const controller = new SessionController({ + send: (data) => { + sent.push(data); + return true; + }, + terminal: () => null, + }); + expect(controller.sendPromptDraft(DRAFT, "r1")).toBe(false); + expect(sent).toEqual([]); + }); + + it("sends the exact prompt-with-draft frame for the open session", () => { + const sent: string[] = []; + const controller = new SessionController({ + send: (data) => { + sent.push(data); + return true; + }, + terminal: () => null, + }); + controller.open("s1"); + sent.length = 0; + expect(controller.sendPromptDraft(DRAFT, "r2")).toBe(true); + expect(JSON.parse(sent[0] ?? "null")).toEqual({ + t: "prompt", + req: "r2", + id: "s1", + draft: DRAFT, + }); + }); + + it("reports a failed send", () => { + const controller = new SessionController({ + send: () => false, + terminal: () => null, + }); + controller.open("s1"); + expect(controller.sendPromptDraft(DRAFT, "r3")).toBe(false); + }); +}); + +describe("Prompt Builder send through the shell", () => { + let root: HTMLDivElement | null = null; + let shell: Shell | null = null; + + afterEach(() => { + shell?.dispose(); + shell = null; + root?.remove(); + root = null; + document.body.innerHTML = ""; + }); + + it("sends the built draft and closes on the host's ok", () => { + const channel = new FakeDataChannel("canopy-session-v1", { ordered: true }); + const store = new SessionStore(); + const catalog = new CatalogStore(); + catalog.replace({ + clis: [{ cli: "muse", label: "Muse", models: [] }], + projects: [{ name: "demo", path: "/home/u/demo" }], + presets: [{ name: "review", preview: "Review the diff." }], + skills: [{ name: "tdd", label: "TDD" }], + }); + root = document.createElement("div"); + document.body.appendChild(root); + + let controller: SessionController | null = null; + shell = createShell( + root, + store, + { + onPair: () => {}, + onConnect: () => {}, + onDisconnect: () => {}, + onRetry: () => {}, + onPairAgain: () => {}, + onOpenSession: () => {}, + onLeaveSession: () => {}, + onKeyPress: () => {}, + onTerminalInput: () => {}, + onComposerSend: () => {}, + onSpawn: () => true, + onCompose: () => true, + onSendDraft: (draft, req) => controller?.sendPromptDraft(draft, req) ?? false, + }, + { catalog }, + ); + controller = new SessionController({ + send: (data) => { + channel.send(data); + return true; + }, + terminal: () => ({ write: () => {}, clear: () => {} }), + }); + const router = createFrameRouter({ store, controller, shell, catalog }); + const frames = createChannelHandler({ + onSnapshot: router.handleSnapshot, + onFrame: router.handleFrame, + }); + channel.onmessage = (ev: MessageEvent) => { + if (typeof ev.data === "string") { + frames.push(ev.data); + } + }; + + shell.showSession("s1", "build"); + controller.open("s1"); + channel.sent.length = 0; + + shell.showBuilder(); + const title = root.querySelector(".section-title"); + if (!title) { + throw new Error("missing section title"); + } + title.value = "Goal"; + title.dispatchEvent(new Event("input", { bubbles: true })); + root.querySelector("#builder-send")?.click(); + + expect(channel.sent).toHaveLength(1); + const frame = JSON.parse(channel.sent[0] ?? "null") as { t: string; req: string; id: string; draft: PromptDraft }; + expect(frame).toEqual({ + t: "prompt", + req: frame.req, + id: "s1", + draft: { sections: [{ title: "Goal", content: "" }], skills: [] }, + }); + expect(root.querySelector(".sheet-overlay")).not.toBeNull(); + + channel.deliver(JSON.stringify({ t: "ok", req: frame.req })); + expect(root.querySelector(".sheet-overlay")).toBeNull(); + }); +}); From 3fc5d52c27852a5539d98630ebfeb60194baaa6d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 21:55:15 -0500 Subject: [PATCH 020/180] feat(sessions): start new agent sessions and build prompts from the browser (reviewer 2 fixes) --- apps/remote/web/src/builder.ts | 39 ++++++++++++++++------------ apps/remote/web/src/composer.ts | 4 +-- apps/remote/web/test/builder.test.ts | 29 ++++++++++++++++++++- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/apps/remote/web/src/builder.ts b/apps/remote/web/src/builder.ts index 307b53f..2bdf58f 100644 --- a/apps/remote/web/src/builder.ts +++ b/apps/remote/web/src/builder.ts @@ -249,15 +249,19 @@ export class Builder { const presets = this.catalog.presets; const selected = this.state.draft.preset ?? ""; const head = ``; - return ( - head + - presets - .map( - (preset) => - ``, - ) - .join("") + const options = presets.map( + (preset) => + ``, ); + // A preset the draft still carries but the host no longer lists stays + // visible (like an unknown skill), so the picker shows what Send will + // send and choosing "No preset" can clear it. + if (selected !== "" && !presets.some((preset) => preset.name === selected)) { + options.push( + ``, + ); + } + return head + options.join(""); } // Skills from the catalog plus any the draft still carries, so a skill @@ -293,15 +297,18 @@ export class Builder { const projects = this.catalog.projects; const selected = this.state.draft.project ?? ""; const head = ``; - return ( - head + - projects - .map( - (project) => - ``, - ) - .join("") + const options = projects.map( + (project) => + ``, ); + // Same as the preset: a project the catalog dropped stays visible so the + // picker never claims "No project" while the draft still sends one. + if (selected !== "" && !projects.some((project) => project.name === selected)) { + options.push( + ``, + ); + } + return head + options.join(""); } private selectedPreset() { diff --git a/apps/remote/web/src/composer.ts b/apps/remote/web/src/composer.ts index 0048753..0512b6b 100644 --- a/apps/remote/web/src/composer.ts +++ b/apps/remote/web/src/composer.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // The composer at the bottom of the session view: a multi-line textarea // and Send, which ships a prompt frame. On ok the text clears; on error -// the text stays and the message shows. The Builder button belongs to the -// next spec; it fires a callback the shell wires. +// the text stays and the message shows. Builder opens the Prompt Builder +// sheet, which the shell owns. import { newReqId } from "./protocol.js"; diff --git a/apps/remote/web/test/builder.test.ts b/apps/remote/web/test/builder.test.ts index 82e20c1..f5c9baa 100644 --- a/apps/remote/web/test/builder.test.ts +++ b/apps/remote/web/test/builder.test.ts @@ -266,6 +266,7 @@ describe("Builder", () => { it("shows unknown preset, skill and project errors beside their fields", () => { open(); + setValue(".section-title", "Goal"); query("#builder-preview").click(); expect(builder.handleError("unknown preset: fast", composed[0]?.req ?? "", "unknown_preset")).toBe( true, @@ -291,7 +292,33 @@ describe("Builder", () => { expect(builder.handleError("late", "no-such-req", "internal")).toBe(false); // The form keeps its draft on error. expect(builder.isOpen).toBe(true); - expect(titles()).toEqual([""]); + expect(titles()).toEqual(["Goal"]); + }); + + it("keeps a draft preset and project the catalog no longer lists selectable", () => { + saveDraft({ sections: [], skills: [], preset: "gone", project: "gone-project" }, storage); + open(); + const preset = query("#builder-preset"); + expect(preset.value).toBe("gone"); + expect([...preset.options].map((option) => option.value)).toEqual(["", "review", "plan", "gone"]); + expect(preset.selectedOptions[0]?.textContent).toBe("gone (not in catalog)"); + const project = query("#builder-project"); + expect(project.value).toBe("gone-project"); + expect([...project.options].map((option) => option.value)).toEqual([ + "", + "demo", + "other", + "gone-project", + ]); + + // Choosing the empty option clears them, so the next send no longer + // carries the values the host just rejected. + select("#builder-preset", ""); + select("#builder-project", ""); + expect(query("#builder-preset").value).toBe(""); + expect(query("#builder-project").value).toBe(""); + query("#builder-send").click(); + expect(sends[0]?.draft).toEqual({ sections: [], skills: [] }); }); it("says so when the sheet is not connected", () => { From 8e99b12f321bf9c89d75601646e14e58ada4e208 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 22:04:19 -0500 Subject: [PATCH 021/180] feat(remote): serve the browser app from the relay Worker --- .github/workflows/deploy-remote.yml | 3 ++ apps/remote/web/playwright.serve.config.ts | 30 +++++++++++++++ apps/remote/web/public/_headers | 4 ++ apps/remote/web/test/headers-file.test.ts | 44 ++++++++++++++++++++++ apps/remote/web/test/serve.spec.ts | 42 +++++++++++++++++++++ apps/remote/worker/README.md | 11 ++++++ apps/remote/worker/vitest.config.ts | 6 +-- apps/remote/worker/wrangler.test.toml | 15 ++++++++ apps/remote/worker/wrangler.toml | 9 +++++ package.json | 3 +- 10 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 apps/remote/web/playwright.serve.config.ts create mode 100644 apps/remote/web/public/_headers create mode 100644 apps/remote/web/test/headers-file.test.ts create mode 100644 apps/remote/web/test/serve.spec.ts create mode 100644 apps/remote/worker/wrangler.test.toml diff --git a/.github/workflows/deploy-remote.yml b/.github/workflows/deploy-remote.yml index e8787c3..7668087 100644 --- a/.github/workflows/deploy-remote.yml +++ b/.github/workflows/deploy-remote.yml @@ -23,6 +23,9 @@ jobs: node-version: 22 cache: npm - run: npm ci + # The Worker serves the browser app from apps/remote/web/dist via the + # [assets] binding, so the web build must exist before wrangler deploy. + - run: npm run build:remote-web - name: Deploy with Wrangler uses: cloudflare/wrangler-action@v3 with: diff --git a/apps/remote/web/playwright.serve.config.ts b/apps/remote/web/playwright.serve.config.ts new file mode 100644 index 0000000..82402ed --- /dev/null +++ b/apps/remote/web/playwright.serve.config.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "@playwright/test"; + +// Serves the built web app from the Workers runtime (`wrangler dev` with the +// production wrangler.toml, assets binding included) and asserts the join: +// assets on `/`, SPA fallback on `/pair`, Worker-only answers on `/auth/*`, +// and the shared security headers. Kept next to playwright.config.ts, which +// it mirrors; run via `npm run test:remote-serve`. +export default defineConfig({ + testDir: "./test", + testMatch: "serve.spec.ts", + timeout: 60_000, + use: { + baseURL: "http://127.0.0.1:8787", + viewport: { width: 360, height: 640 }, + channel: "chrome", + launchOptions: { + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }, + }, + webServer: { + // SESSION_SECRET is a Worker secret in prod; locally --var provides the + // minimum env /auth/me needs, so it answers its real 401 (no cookie). + command: + "npm run build:remote-web && npx wrangler dev --config ../worker/wrangler.toml --port 8787 --var SESSION_SECRET:dev-serve-secret", + url: "http://127.0.0.1:8787/", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/remote/web/public/_headers b/apps/remote/web/public/_headers new file mode 100644 index 0000000..ecb541a --- /dev/null +++ b/apps/remote/web/public/_headers @@ -0,0 +1,4 @@ +/* + Content-Security-Policy: default-src 'self'; connect-src 'self' wss://remote.univerlab.org; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none' + Referrer-Policy: no-referrer + X-Content-Type-Options: nosniff diff --git a/apps/remote/web/test/headers-file.test.ts b/apps/remote/web/test/headers-file.test.ts new file mode 100644 index 0000000..31bb18e --- /dev/null +++ b/apps/remote/web/test/headers-file.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SECURITY_HEADERS } from "../../worker/src/protocol.js"; + +/** + * The static `_headers` file is the only thing decorating asset responses + * (Cloudflare's assets binding honours it in dev and prod), so it must + * carry exactly the same security headers as the Worker's own responses. + * Single source of truth: the constants exported by worker/src/protocol.ts. + */ +describe("web/public/_headers", () => { + const resolveFromRoot = (path: string): string => + isAbsolute(path) ? path : join(process.cwd(), path); + + const file = readFileSync( + resolveFromRoot("apps/remote/web/public/_headers"), + "utf8", + ); + const lines = file.split("\n"); + + it("routes every path", () => { + expect(lines[0]).toBe("/*"); + }); + + it("declares the exact CSP used by the Worker", () => { + const csp = lines.find((line) => + line.trim().toLowerCase().startsWith("content-security-policy:"), + ); + expect(csp?.slice(" Content-Security-Policy:".length).trim()).toBe( + SECURITY_HEADERS["content-security-policy"], + ); + }); + + it("declares Referrer-Policy and X-Content-Type-Options", () => { + const names = lines + .filter((line) => line.startsWith(" ")) + .map((line) => line.trim().split(":")[0].toLowerCase()); + expect(names).toContain("referrer-policy"); + expect(names).toContain("x-content-type-options"); + expect(names).toHaveLength(3); + }); +}); diff --git a/apps/remote/web/test/serve.spec.ts b/apps/remote/web/test/serve.spec.ts new file mode 100644 index 0000000..6b14385 --- /dev/null +++ b/apps/remote/web/test/serve.spec.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from "@playwright/test"; + +// The web build is served from the relay Worker via its [assets] binding, on +// the same origin as the WebSocket. The CSP values mirror +// apps/remote/worker/src/protocol.ts; the exact text is covered by the +// unit tests (worker/test/headers.test.ts, web/test/headers-file.test.ts). +const CSP_PREFIX = "default-src 'self'; connect-src 'self' wss://"; +const HEADERS: Array<[string, string]> = [ + ["content-security-policy", CSP_PREFIX], + ["referrer-policy", "no-referrer"], + ["x-content-type-options", "nosniff"], +]; + +test("serves the app on / with the shared security headers", async ({ + page, +}) => { + const response = await page.goto("/"); + expect(response?.status()).toBe(200); + for (const [name, value] of HEADERS) { + const header = response?.headers()[name]; + expect(header, `missing header ${name}`).toBeTruthy(); + expect(header).toContain(value); + } + await expect(page.locator("#app")).toBeAttached(); + await expect(page).toHaveTitle("Canopy remote"); +}); + +test("falls back to the app on /pair", async ({ page }) => { + const response = await page.goto("/pair"); + expect(response?.status()).toBe(200); + const html = await page.content(); + expect(html).toContain('
    '); + await expect(page).toHaveTitle("Canopy remote"); +}); + +test("answers /auth/me with 401 JSON without a session", async ({ request }) => { + const response = await request.get("/auth/me"); + expect(response.status()).toBe(401); + expect(response.headers()["content-type"]).toContain("application/json"); + expect(await response.json()).toEqual({ error: "unauthorized" }); +}); diff --git a/apps/remote/worker/README.md b/apps/remote/worker/README.md index aafa276..ef4cf49 100644 --- a/apps/remote/worker/README.md +++ b/apps/remote/worker/README.md @@ -64,6 +64,17 @@ globals. *Edit Workers*. The account id is inlined in `wrangler.toml`, so no second secret is needed. +## Static assets + +The Worker serves the built browser app (`apps/remote/web/dist`) through the +`[assets]` binding in `wrangler.toml`: `/` and any non-API path serve the app +(`single-page-application` fallback, so `/pair` returns `index.html`), while +`/auth/*`, `/ws/*` and `/turn` always reach the Worker code +(`run_worker_first`). Responses built by the Worker get the security headers +from `src/protocol.ts`; asset responses get the identical headers from the +`_headers` file in `apps/remote/web/public/`, which the deploy workflow keeps +in sync by running `npm run build:remote-web` before `wrangler deploy`. + ## Deploy `.github/workflows/deploy-remote.yml`, **manual only** diff --git a/apps/remote/worker/vitest.config.ts b/apps/remote/worker/vitest.config.ts index 3ec7dce..1930a83 100644 --- a/apps/remote/worker/vitest.config.ts +++ b/apps/remote/worker/vitest.config.ts @@ -6,9 +6,9 @@ export default defineWorkersConfig({ include: ["apps/remote/worker/test/**/*.test.ts"], poolOptions: { workers: { - // Single-file Worker: the pool loads this config, so tests speak to - // the same code and bindings `wrangler deploy` will ship. - wrangler: { configPath: "./wrangler.toml" }, + // Test-only config without the [assets] binding: unit tests never + // need a built ../web/dist. See wrangler.test.toml. + wrangler: { configPath: "./wrangler.test.toml" }, // The room's sqlite-backed class leaves -shm files behind that this // pool's isolated-storage copy chokes on; the live tests use unique // room names, so sharing one namespace is safe. diff --git a/apps/remote/worker/wrangler.test.toml b/apps/remote/worker/wrangler.test.toml new file mode 100644 index 0000000..26b8f44 --- /dev/null +++ b/apps/remote/worker/wrangler.test.toml @@ -0,0 +1,15 @@ +# Test-only config for the vitest pool: a copy of wrangler.toml minus the +# [assets] binding (no built ../web/dist needed), account_id and routes — +# the pool binds nothing and unit tests never call the HTTP routes layer +# that depends on assets. +name = "canopy-remote" +main = "src/index.ts" +compatibility_date = "2026-10-26" + +[[durable_objects.bindings]] +name = "USER_ROOM" +class_name = "UserRoom" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["UserRoom"] diff --git a/apps/remote/worker/wrangler.toml b/apps/remote/worker/wrangler.toml index a8e0044..b6ec721 100644 --- a/apps/remote/worker/wrangler.toml +++ b/apps/remote/worker/wrangler.toml @@ -14,6 +14,15 @@ account_id = "d7b81bdd721f503968523dd903d967be" routes = [{ pattern = "remote.univerlab.org/*", zone_name = "univerlab.org" }] +# Serve the built browser app from apps/remote/web/dist on the same origin, +# so the page and its WebSocket share remote.univerlab.org. Paths matching +# `run_worker_first` always reach the Worker code first; everything else +# falls back to the assets binding (SPA: unknown paths serve index.html). +[assets] +directory = "../web/dist" +not_found_handling = "single-page-application" +run_worker_first = ["/auth/*", "/ws/*", "/turn"] + # One room per GitHub user id. The room uses the WebSocket hibernation API # with per-socket attachments only — it never touches the storage API. [[durable_objects.bindings]] diff --git a/package.json b/package.json index 4f75872..8a18667 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test:worker": "vitest run --config apps/graphs/vitest.config.ts", "test:remote-worker": "vitest run --config apps/remote/worker/vitest.config.ts", "validate": "tsx src/validate.ts", - "test:remote-smoke": "playwright test --config apps/remote/web/playwright.config.ts" + "test:remote-smoke": "playwright test --config apps/remote/web/playwright.config.ts", + "test:remote-serve": "playwright test --config apps/remote/web/playwright.serve.config.ts" }, "dependencies": { "@cfworker/json-schema": "^4.1.1", From 20f082cd7215bc1101341df79fc7244250883ed9 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 26 Oct 2026 22:07:37 -0500 Subject: [PATCH 022/180] feat(remote): serve the browser app from the relay Worker (reviewer 2 fixes) --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 174961f..f556a06 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["node_modules/", "dist/", "apps/remote/web/dist/", ".wrangler/", "coverage/"] }, + { ignores: ["node_modules/", "dist/", "apps/remote/web/dist/", ".wrangler/", "coverage/", "**/.wrangler/"] }, js.configs.recommended, ...tseslint.configs.recommended, { From ebb2ee0fba2046156fefeb02a5aa4173d4b2922e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 27 Oct 2026 00:59:47 -0500 Subject: [PATCH 023/180] fix(graphs): show an empty catalogue instead of "GitHub is busy", render README lists and gate the browser tests --- .design-note.tmp.md | 108 ++++++++++++++++ .github/workflows/ci.yml | 13 ++ apps/graphs/src/official.ts | 9 +- apps/graphs/src/pages/copy.ts | 5 +- apps/graphs/src/pages/md.ts | 121 +++++++++++++++++- apps/graphs/src/pages/routes.ts | 23 ++-- .../graphs/test/__snapshots__/md.test.ts.snap | 18 +++ apps/graphs/test/api.test.ts | 10 ++ apps/graphs/test/md-fixture.ts | 40 ++++++ apps/graphs/test/md.test.ts | 115 +++++++++++++++++ apps/graphs/test/official.test.ts | 40 ++++++ apps/graphs/test/pages.test.ts | 24 +++- apps/remote/web/playwright.config.ts | 1 - apps/remote/web/playwright.serve.config.ts | 1 - package.json | 3 +- tests/ci.test.ts | 94 ++++++++++++++ 16 files changed, 604 insertions(+), 21 deletions(-) create mode 100644 .design-note.tmp.md create mode 100644 apps/graphs/test/__snapshots__/md.test.ts.snap create mode 100644 apps/graphs/test/md-fixture.ts create mode 100644 apps/graphs/test/md.test.ts create mode 100644 tests/ci.test.ts diff --git a/.design-note.tmp.md b/.design-note.tmp.md new file mode 100644 index 0000000..48939ce --- /dev/null +++ b/.design-note.tmp.md @@ -0,0 +1,108 @@ +# Design note — empty catalogue, README lists, e2e gate + +Repo conventions observed and reused: apps/graphs is strict TS with typed-value errors (never exceptions), all prose in pages/copy.ts, tests on injected fakes (test/fakes.ts: FakeCache, fakeFetch with Stub table, dirBody/fileBody), vitest under the Cloudflare workers pool (apps/graphs/vitest.config.ts), Playwright specs in apps/remote/web/test/. Files this design touches and nothing else: + +apps/graphs/src/official.ts, apps/graphs/src/pages/copy.ts, apps/graphs/src/pages/routes.ts, apps/graphs/src/pages/md.ts, package.json, apps/remote/web/playwright.config.ts, apps/remote/web/playwright.serve.config.ts, .github/workflows/ci.yml, apps/graphs/test/official.test.ts, apps/graphs/test/pages.test.ts, apps/graphs/test/api.test.ts, new apps/graphs/test/md.test.ts, new apps/graphs/test/md-fixture.ts. Commit message when done: the spec name verbatim as the Conventional Commits subject (canopy numbers it). + +## FR1 — a missing registry folder is an empty catalogue + +Facts: github.ts `errorFor` maps status 404/410 to `{kind:"not-found", status:404}`; every other failure is "rate-limit" or "upstream". `listOfficial` (official.ts:74-130) loops `["graph","node"]` and `return listing` on ANY ApiError. + +Change, in `listOfficial` only: +- Replace `if (isApiError(listing)) { return listing; }` with: if `listing.kind === "not-found"` → `log("official: registry/s is absent; listing it as empty")` and continue the loop leaving `directories[kind] = []` (its initial value). Every other ApiError kind still `return listing` → callers keep answering the calm 503 busy page. Do NOT touch `officialStars` (a repo exists even without the folder; its errors already degrade to `stars: null`). +- No caller edits: pages/routes.ts `homeResponse`/`llmsResponse` and api.ts `case "official"` only check `isApiError(listing)`; with the change they receive `{graphs:[],nodes:[]}` and render the empty home / header-only llms.txt / 200 JSON automatically. Consequence to accept: llms.txt answers 200 with just its header line — absent registry = empty catalogue, same rule as the home. + +Empty lines (exact wording is spec-mandated). Today `docList`/`docListMarkdown` (routes.ts:188-208) use `EMPTY_LIST` = "Nothing here yet." (copy.ts:46); that constant is rendered ONLY by the home Official lists (searchPage uses NO_RESULTS), so replace it: +- copy.ts: delete `EMPTY_LIST`; add + `export const EMPTY_OFFICIAL_GRAPHS = "No official graphs published yet.";` + `export const EMPTY_OFFICIAL_NODES = "No official nodes published yet.";` + ("and the same for nodes" = the analogous nodes line, pinned here.) +- routes.ts: `docList(docs: Doc[], emptyLine: string)` and `docListMarkdown(docs: Doc[], emptyLine: string)`; the empty branches use `emptyLine`. `homePage` passes `docList(graphs, EMPTY_OFFICIAL_GRAPHS)`, `docList(nodes, EMPTY_OFFICIAL_NODES)` and the same for the markdown twin. Every other call site passes non-empty lists (searchPage short-circuits on empty), so no other site changes. Update the import list (drop EMPTY_LIST, add the two constants). + +## FR2 — md.ts renders lists + +Scope pin: `renderMarkdown` output contract for lists, inside the existing "safe by construction, no dependency" model. The block loop (md.ts:69-98) checks fence, heading, blank, paragraph. Insert list detection after the heading check, before blank. Module docstring (md.ts:3-11) gains "lists" in the supported set. + +Line grammar (regexes, anchored): +- unordered: `/^(\s*)[-*]\s+(\S.*)$/` +- ordered: `/^(\s*)\d+\.\s+(\S.*)$/` +- The `\S.*` requires non-empty text; "- " with nothing after stays a paragraph. Only spaces count as indent (a leading tab makes it not a list item). "as today" for emphasis means: emphasis is NOT rendered today (INLINE_RE is code+links only) and stays literal escaped text — do not add `*em*`/`_em_` support. + +State and representation (pin exactly, keeps nesting trivial): +``` +interface ListItem { text: string; child: ListBlock | null } +interface ListBlock { ordered: boolean; indent: number; items: ListItem[] } +``` +plus `let openList: ListBlock | null = null` and a `flushList()` that renders and appends the block to `blocks` and clears it. `flushParagraph()` calls `flushList()` first; list-item detection calls `flushParagraph()` first (so a heading, fence or blank line always closes an open list). + +Per list line with indent `i` and `ordered` flag: +- `openList === null` → open a block `{ordered, indent: i, items: [{text, child: null}]}`. +- `d = i - openList.indent`; `d >= 2` → nested: take `openList.items.at(-1)`; if its `child` is null, create `{ordered, indent: i, items: []}` as child; push `{text, child: null}` into that child. Deeper indents (4 spaces etc.) clamp into this one child — exactly one nesting level. +- `-1 <= d < 2` → sibling: push `{text, child: null}` onto `openList.items`. If `ordered` differs from `openList.ordered`, instead `flushList()` and open a new block (marker switch closes the list). +- `d < -1` → `flushList()` and re-parse the line fresh (its indent becomes the new base). + +`flushList()` renders recursively: `
      `/`
    - +
    + +
    +
    +

    Canopy graphs

    +

    Part of UniverLab · EXP-001 Canopy graphs

    +
    + +

    + + Artifact conceived and forged at UniverLab +

    +
    +
    + + + + @@ -530,160 +804,224 @@ hello-node Example catalogue node: drafts an implementation plan. hello-node exports[`route snapshots > renders /n/ with the one node 1`] = ` " - + hello-node · graphs.univerlab.org - - - + + + - + +
    -

    A catalogue of Canopy graphs and nodes.

    -
    +

    hello-node

    Example catalogue node: drafts an implementation plan.

    -

    UniverLab/canopy-web — 7 stars

    +

    UniverLab/canopy-web 7 stars

    Star on GitHub

    Install

    canopy node install hello-node
    @@ -692,9 +1030,36 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; }

    README

    - +
    + +
    +
    +

    Canopy graphs

    +

    Part of UniverLab · EXP-001 Canopy graphs

    +
    + +

    + + Artifact conceived and forged at UniverLab +

    +
    +
    + +
    + + @@ -703,165 +1068,256 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; } exports[`route snapshots > renders /search with community results 1`] = ` " - + Results for “flow” · graphs.univerlab.org - - - + + + - + +
    -

    A catalogue of Canopy graphs and nodes.

    -
    +
    +

    Results for “flow”

    -

    Results for “flow”

    -
    • hello-graph — 2 starsMinimal example graph: implement, check, gate, and a router with two routes.
    +
    • hello-graph 2 starsMinimal example graph: implement, check, gate, and a router with two routes.
    - +
    + +
    +
    +

    Canopy graphs

    +

    Part of UniverLab · EXP-001 Canopy graphs

    +
    + +

    + + Artifact conceived and forged at UniverLab +

    +
    +
    + +
    + + @@ -870,160 +1326,224 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; } exports[`the plate drawing > draws the five nodes as Canopy shapes in a scrolling figure 1`] = ` " - + hello-graph · graphs.univerlab.org - - - + + + - + +
    -

    A catalogue of Canopy graphs and nodes.

    -
    +

    hello-graph

    Five nodes with one fail edge, for the drawing test.

    -

    UniverLab/canopy-web — 7 stars

    +

    UniverLab/canopy-web 7 stars

    Star on GitHub

    Install

    canopy graph install hello-graph
    @@ -1036,9 +1556,36 @@ h2 { font-size: 1.35rem; margin: 2rem 0 0.5rem; }
    canopy graph install hello-graph

    Read the docs and run npm test.

    - +
    + +
    +
    +

    Canopy graphs

    +

    Part of UniverLab · EXP-001 Canopy graphs

    +
    + +

    + + Artifact conceived and forged at UniverLab +

    +
    +
    + + + + diff --git a/apps/graphs/test/e2e/visual.spec.ts b/apps/graphs/test/e2e/visual.spec.ts new file mode 100644 index 0000000..5c00522 --- /dev/null +++ b/apps/graphs/test/e2e/visual.spec.ts @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The catalogue's dress, checked in a browser against the fixture Worker: + * the Canopy surface, the three faces loaded from the kit, the honeycomb + * behind the content, the mono wordmark, the colophon footer, and no + * horizontal page scroll at 390 px. Three pages (home, a graph, a node) at + * the two reference sizes; a screenshot per page is attached to the report + * for the operator, never compared. + */ +import { expect, test } from "@playwright/test"; + +const VIEWPORTS = [ + { label: "mobile", width: 390, height: 844 }, + { label: "desktop", width: 1280, height: 900 }, +] as const; + +const PAGES = [ + { label: "home", path: "/", heading: "A catalogue of Canopy graphs and nodes." }, + { label: "graph", path: "/g/hello-graph", heading: "hello-graph" }, + { label: "node", path: "/n/hello-node", heading: "hello-node" }, +] as const; + +/** #012624, the Liquid Abyss canvas of the Canopy page. */ +const SURFACE_BG = "rgb(1, 38, 36)"; + +for (const viewport of VIEWPORTS) { + for (const target of PAGES) { + test(`${target.label} at ${viewport.width}x${viewport.height}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto(target.path, { waitUntil: "load" }); + + // The Canopy surface, named on and painted on . + await expect(page.locator("html")).toHaveAttribute("data-surface", "tui"); + const surface = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(surface).toBe(SURFACE_BG); + + // The kit's three faces loaded for real (the unit tests only see the CSS). + const faces = await page.evaluate(async () => { + await document.fonts.ready; + return { + status: document.fonts.status, + display: document.fonts.check(`16px "Old Standard TT"`), + body: document.fonts.check(`16px "EB Garamond"`), + mono: document.fonts.check(`16px "Sometype Mono"`), + heading: getComputedStyle(document.querySelector("h1") ?? document.body).fontFamily, + wordmark: getComputedStyle(document.querySelector(".wordmark") ?? document.body).fontFamily, + }; + }); + expect(faces.status).toBe("loaded"); + expect(faces.display).toBe(true); + expect(faces.body).toBe(true); + expect(faces.mono).toBe(true); + expect(faces.heading).toContain("Old Standard TT"); + expect(faces.wordmark).toContain("Sometype Mono"); + + // The live honeycomb is mounted, hidden from assistive tech, unfocusable. + const fondo = await page.evaluate(() => { + const host = document.getElementById("fondo"); + const canvas = host?.querySelector("canvas"); + return { + host: host !== null, + ariaHidden: host?.getAttribute("aria-hidden") ?? null, + canvas: canvas !== undefined && canvas !== null, + tabIndex: canvas?.tabIndex ?? null, + pointerEvents: canvas === null || canvas === undefined ? null : getComputedStyle(canvas).pointerEvents, + sized: canvas === null || canvas === undefined ? false : canvas.clientWidth > 0, + }; + }); + expect(fondo.host).toBe(true); + expect(fondo.ariaHidden).toBe("true"); + expect(fondo.canvas).toBe(true); + expect(fondo.tabIndex).toBe(-1); + expect(fondo.pointerEvents).toBe("none"); + expect(fondo.sized).toBe(true); + + // The wordmark and the UniverLab colophon. + const wordmark = page.locator("header .wordmark"); + await expect(wordmark).toHaveText("graphs"); + await expect(wordmark).toHaveAttribute("href", "https://univerlab.org/canopy/"); + const colophon = page.locator("footer.look-colophon"); + await expect(colophon).toBeVisible(); + await expect(colophon).toContainText("Canopy graphs"); + await expect(colophon).toContainText("EXP-001"); + + // One page-level h1 (the README may repeat its own title inside .readme). + const headings = page.locator("main.look-col > h1"); + await expect(headings).toHaveCount(1); + await expect(headings).toHaveText(target.heading); + + // The plate scrolls inside itself; the page never scrolls sideways. + if (target.label !== "home") { + const plate = page.locator("figure.plate"); + await expect(plate).toHaveCount(1); + const overflow = await plate.evaluate((el) => getComputedStyle(el).overflowX); + expect(overflow).toBe("auto"); + } + const width = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + view: window.innerWidth, + })); + expect(width.view).toBeLessThanOrEqual(viewport.width); + expect(width.scroll).toBeLessThanOrEqual(width.view); + + // A visible focus outline on the first tabbable element. + await page.keyboard.press("Tab"); + const focus = await page.evaluate(() => { + const active = document.activeElement; + const style = active === null ? null : getComputedStyle(active); + return { label: active?.textContent ?? "", outline: style?.outlineStyle ?? "none" }; + }); + expect(focus.label).toContain("graphs"); + expect(focus.outline).not.toBe("none"); + + // Kept beside the report for the operator's review (no baseline). + const shot = await page.screenshot({ + fullPage: true, + path: testInfo.outputPath(`${target.label}-${viewport.label}.png`), + }); + await testInfo.attach(`${target.label}-${viewport.label}`, { + body: shot, + contentType: "image/png", + }); + }); + } +} diff --git a/apps/graphs/test/e2e/worker.ts b/apps/graphs/test/e2e/worker.ts new file mode 100644 index 0000000..fc8841e --- /dev/null +++ b/apps/graphs/test/e2e/worker.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The visual e2e fixture Worker: the production Worker over the page tests' + * stub table, so `wrangler dev` serves the catalogue with fixture data and + * no network at all. The fake fetch answers every GitHub URL the pages ask + * for and a miss is a 404, exactly as in the unit tests. + */ +import { createWorker } from "../../src/index.js"; +import { FakeCache, fakeFetch } from "../fakes.js"; +import { officialStubs } from "../stubs.js"; + +const github = fakeFetch([...officialStubs()]); + +export default createWorker({ fetchImpl: github.fetchImpl, cache: new FakeCache() }); diff --git a/apps/graphs/test/pages.test.ts b/apps/graphs/test/pages.test.ts index 4d97cde..2832031 100644 --- a/apps/graphs/test/pages.test.ts +++ b/apps/graphs/test/pages.test.ts @@ -6,58 +6,15 @@ */ import { describe, expect, it } from "vitest"; import { NOT_PAGE, handlePage } from "../src/pages/routes.js"; -import { BUSY_BODY, BUSY_HEADING, COPY_BUTTON, EMPTY_OFFICIAL_GRAPHS, EMPTY_OFFICIAL_NODES, FOOTER_HREF, FOOTER_TEXT, SEARCH_BUTTON, SEARCH_LABEL } from "../src/pages/copy.js"; +import { BUSY_BODY, BUSY_HEADING, COPY_BUTTON, EMPTY_OFFICIAL_GRAPHS, EMPTY_OFFICIAL_NODES, SEARCH_BUTTON, SEARCH_LABEL } from "../src/pages/copy.js"; import { FakeCache, dirBody, fakeFetch, fileBody, type Stub } from "./fakes.js"; -import { - EVIL_README_TEXT, - FIVE_NODE_GRAPH_TEXT, - PAGES_README_TEXT, -} from "./pages-fixture.js"; -import { VALID_GRAPH_TEXT, VALID_NODE_TEXT } from "./documents-fixture.js"; +import { EVIL_README_TEXT, PAGES_README_TEXT } from "./pages-fixture.js"; +import { VALID_GRAPH_TEXT } from "./documents-fixture.js"; +import { communityStubs, officialStubs, REPO_DIR, searchStubs } from "./stubs.js"; const ORIGIN = "https://graphs.univerlab.org"; -const REPO_DIR = "repos/UniverLab/canopy-web"; -const SEARCH = "search/repositories"; const HTML = { "content-type": "text/html" }; -/** Everything the official listing needs: one five-node graph, one node. */ -function officialStubs(): Stub[] { - return [ - { url: `${REPO_DIR}/contents/registry/graphs`, status: 200, body: dirBody([{ name: "hello-graph", type: "dir" }]) }, - { url: `${REPO_DIR}/contents/registry/nodes`, status: 200, body: dirBody([{ name: "hello-node", type: "dir" }]) }, - { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/graph.json`, status: 200, body: fileBody("graphs/hello-graph/graph.json", FIVE_NODE_GRAPH_TEXT) }, - { url: `${REPO_DIR}/contents/registry/graphs/hello-graph/README.md`, status: 200, body: fileBody("graphs/hello-graph/README.md", PAGES_README_TEXT) }, - { url: `${REPO_DIR}/contents/registry/nodes/hello-node/node.json`, status: 200, body: fileBody("nodes/hello-node/node.json", VALID_NODE_TEXT) }, - { url: `${REPO_DIR}/contents/registry/nodes/hello-node/README.md`, status: 404, body: {} }, - { url: REPO_DIR, status: 200, body: { stargazers_count: 7 } }, - ]; -} - -/** One community repository holding a graph called flow. */ -function communityStubs(): Stub[] { - return [ - { url: "repos/acme/things/contents/graphs/flow/graph.json", status: 200, body: fileBody("graphs/flow/graph.json", VALID_GRAPH_TEXT) }, - { url: "repos/acme/things/contents/graphs/flow/README.md", status: 404, body: {} }, - ]; -} - -/** The search answer the /search tests read: one repository holding flow. */ -function searchStubs(): Stub[] { - return [ - ...officialStubs(), - ...communityStubs(), - { - url: SEARCH, - status: 200, - body: { - total_count: 1, - items: [{ name: "things", owner: { login: "acme" }, default_branch: "main", stargazers_count: 2 }], - }, - }, - { url: "repos/acme/things/git/trees/main", status: 200, body: { tree: [{ path: "graphs/flow/graph.json", type: "blob" }] } }, - ]; -} - interface Options { stubs?: Stub[]; accept?: string; @@ -159,14 +116,16 @@ describe("the sanitizer", () => { stub.url.endsWith("README.md") ? { ...stub, body: fileBody("graphs/hello-graph/README.md", EVIL_README_TEXT) } : stub, ); const html = await (await call("/g/hello-graph", { stubs: evil })).text(); + // Scope to the rendered README: the colophon below carries real images. + const readme = html.slice(html.indexOf(`
    `), html.indexOf(`
    `)); // The tags survive only as escaped text, never as markup. - expect(html).toContain("<script>alert(1)</script>"); - expect(html).toContain("<img src=x onerror="); - expect(html).not.toContain(""); - expect(html).not.toContain('onerror="alert'); - expect(html).not.toContain('href="javascript:'); - expect(html).toContain(``); + expect(readme).toContain("<script>alert(1)</script>"); + expect(readme).toContain("<img src=x onerror="); + expect(readme).not.toContain(""); + expect(readme).not.toContain('onerror="alert'); + expect(readme).not.toContain('href="javascript:'); + expect(readme).toContain(``); }); }); @@ -176,7 +135,8 @@ describe("the plate drawing", () => { expect(html).toMatchSnapshot(); // The figure scrolls horizontally on narrow screens; the page never overflows. expect(html).toContain(`
    `); - expect(html).toContain(`.plate { margin: 1rem 0; overflow-x: auto; max-width: 100%; }`); + // The figure scrolls horizontally inside itself; the page never overflows. + expect(html).toMatch(/figure\.plate \{[^}]*max-width: 100%;[^}]*overflow-x: auto;/); // One labelled shape per node: agents are circles, checks rounded // squares, gates and routers diamonds — the router reads as a gate. for (const label of ["build", "test", "gate", "triage", "docs"]) { @@ -270,21 +230,39 @@ describe("data-layer failures", () => { }); describe("the page shell", () => { - it("carries the fonts, both theme pairs, the hour script, the footer and a keyboard copy button", async () => { + it("dresses the page in the Canopy look kit: surface, faces, honeycomb and colophon", async () => { const html = await (await call("/g/hello-graph")).text(); - // The visual system of univerlab.org. - expect(html).toContain("fonts.googleapis.com/css2"); - expect(html).toContain("Old+Standard+TT"); - expect(html).toContain("EB+Garamond"); - expect(html).toContain("Sometype+Mono"); - for (const hex of ["#f4f0ea", "#1d1510", "#222928", "#ecebe4", "#3fb8b0"]) { - expect(html).toContain(hex); - } - expect(html).toContain("localStorage"); - expect(html).toContain("getHours"); - expect(html).toContain(`${FOOTER_TEXT}`); + // The Canopy surface and the kit's three stylesheets, from their folders. + expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain(``); + // The honeycomb: a hidden host and the kit's bundle behind the content. + expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain(`mountCanopyBackground`); + expect(html).toContain(`/look/fondo/assets/`); + expect(html).toContain(`
    `); + // The kit's variables, and both of its faces, drive the page stylesheet. + expect(html).toContain("background: var(--surface-bg)"); + expect(html).toContain("color: var(--surface-ink)"); + expect(html).toContain("font-family: var(--font-body)"); + expect(html).toContain("font-family: var(--font-mono)"); + expect(html).toContain("font-family: var(--font-display)"); + expect(html).toContain("var(--radius-card)"); + expect(html).toContain("var(--cta-gradient)"); + // The header wordmark and the UniverLab colophon as footer. + expect(html).toContain(`graphs`); + expect(html).toContain(`
    `); + expect(html).toContain("Part of UniverLab · EXP-001 Canopy graphs"); + expect(html).toContain(`/look/colophon/frieze-1920.avif`); + // The day/night toggle is gone: the Canopy page is a dark surface. + expect(html).not.toContain("theme-toggle"); + expect(html).not.toContain("localStorage"); + expect(html).not.toContain("getHours"); // Accessibility and weight: no framework, real buttons, reduced motion. expect(html).toContain("prefers-reduced-motion"); + expect(html).toContain(":focus-visible { outline: 2px solid var(--teal)"); expect(html).toMatch(/ -
    `; + + + ${COLOPHON}`; root.querySelector("#signin-go")?.addEventListener("click", () => { globalThis.location.href = "/auth/login"; }); @@ -206,7 +220,8 @@ export function createShell( - `; + + ${COLOPHON}`; root.querySelector("#pair-form")?.addEventListener("submit", (ev) => { ev.preventDefault(); snapshotDraft(); @@ -347,6 +362,9 @@ export function createShell( }; const render = () => { + // The view name on lets the stylesheet dim the honeycomb + // behind the terminal (body[data-view="session"]). + document.body.dataset.view = route; if (route === "signin") { renderSignIn(); } else if (route === "pair") { diff --git a/apps/remote/web/src/builder.ts b/apps/remote/web/src/builder.ts index 2bdf58f..34e89cd 100644 --- a/apps/remote/web/src/builder.ts +++ b/apps/remote/web/src/builder.ts @@ -197,7 +197,7 @@ export class Builder {
    ${esc(this.state.preview ?? "")}
    - +
    `; @@ -236,9 +236,9 @@ export class Builder {
    - - - + + +
    `; }) diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts index ec17b73..3920869 100644 --- a/apps/remote/web/src/fake-host.ts +++ b/apps/remote/web/src/fake-host.ts @@ -8,6 +8,7 @@ import { createShell } from "./app.js"; import { CatalogStore } from "./catalog.js"; import { createChannelHandler } from "./channel.js"; import { b64encode } from "./crypto.js"; +import { mountCanopyBackground } from "../../../../packages/look/fondo/index.js"; import { buildCatalog, buildList, newReqId } from "./protocol.js"; import type { PromptDraft, SessionItem } from "./protocol.js"; import { createFrameRouter } from "./router.js"; @@ -55,6 +56,12 @@ function main(): void { if (!root) { throw new Error("Missing #app element"); } + // The same honeycomb the real entry mounts, so the smoke run sees the + // dressed page. + const fondo = document.getElementById("fondo"); + if (fondo) { + mountCanopyBackground(fondo, "/look/fondo/assets/"); + } const store = new SessionStore(); const catalog = new CatalogStore(); const sent: unknown[] = []; diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts index 12b48fe..2b60923 100644 --- a/apps/remote/web/src/main.ts +++ b/apps/remote/web/src/main.ts @@ -10,6 +10,7 @@ import { CatalogStore } from "./catalog.js"; import { createChannelHandler } from "./channel.js"; import { generateDeviceKey } from "./crypto.js"; import type { DeviceKey } from "./crypto.js"; +import { mountCanopyBackground } from "../../../../packages/look/fondo/index.js"; import { keyBytes } from "./keys.js"; import { buildCatalog, buildCompose, buildList, buildSpawn, newReqId } from "./protocol.js"; import { runPairing } from "./pairing.js"; @@ -66,6 +67,11 @@ async function main(): Promise { if (!root) { throw new Error("Missing #app element"); } + // The live honeycomb behind the page, from the shared look kit. + const fondo = document.getElementById("fondo"); + if (fondo) { + mountCanopyBackground(fondo, "/look/fondo/assets/"); + } const prefill = consumePairFragment(); const login = await fetchLogin(); diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index 418d17f..662d2a3 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -1,47 +1,29 @@ /* SPDX-License-Identifier: Apache-2.0 */ -/* Canopy remote: every color, font and spacing decision lives in these - * custom properties so the visual identity review can retheme without - * touching the components. Light and dark follow the OS setting. */ +/* Canopy remote: the Canopy page look. The shared kit (packages/look, + * copied to public/look by `npm run build:look`) owns the tokens and the + * three typefaces; this file maps the app's semantic variables onto them + * and dresses every widget. Always the dark Liquid Abyss surface — a + * remote session is read in a terminal, and the OS preference no longer + * flips the palette. */ :root { - --bg: #ffffff; - --ink: #1d1510; - --muted: #6b6259; - --line: #e3ddd4; - --accent: #0e7c76; - --accent-ink: #ffffff; - --danger: #a4262c; - --dot-run: #1e9e50; - --dot-idle: #c77700; - --dot-dead: #8a8378; - --dot-unknown: #b3aa9d; - --term-bg: #101210; - --key-bg: #f1ede6; - --key-ink: #1d1510; - --radius: 8px; + color-scheme: dark; + --bg: var(--surface-bg); + --ink: var(--surface-ink); + --muted: var(--surface-ink-dim); + --line: var(--surface-line); + --accent: var(--teal); + --accent-ink: #011d1c; + --danger: #e08086; + --dot-run: #3fd07a; + --dot-idle: #e09a2b; + --dot-dead: #6f6a60; + --dot-unknown: #57534b; + --term-bg: var(--bg-deep); + --key-bg: var(--surface-bg-raise); + --key-ink: var(--surface-ink); + --radius: var(--radius-ctl); --gap: 0.5rem; - --font-body: system-ui, -apple-system, "Segoe UI", sans-serif; - --font-mono: "Sometype Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - color-scheme: light dark; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #222928; - --ink: #ecebe4; - --muted: #a8a49a; - --line: #3a423f; - --accent: #3fb8b0; - --accent-ink: #10201e; - --danger: #e08086; - --dot-run: #3fd07a; - --dot-idle: #e09a2b; - --dot-dead: #6f6a60; - --dot-unknown: #57534b; - --term-bg: #0c0c0c; - --key-bg: #323937; - --key-ink: #ecebe4; - } } * { @@ -59,11 +41,28 @@ body { background: var(--bg); color: var(--ink); font-family: var(--font-body); + font-size: 1.0625rem; line-height: 1.5; padding-bottom: env(safe-area-inset-bottom); } +/* The live honeycomb: a fixed canvas layer behind the whole page. The + * session view dims it so the terminal stays legible. */ +.fondo { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.55; +} + +body[data-view="session"] .fondo { + opacity: 0.15; +} + #app { + position: relative; + z-index: 1; margin: 0 auto; max-width: 48rem; min-height: 100dvh; @@ -77,6 +76,29 @@ main { padding-right: max(1rem, env(safe-area-inset-right)); } +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: 400; + line-height: 1.2; + color: var(--ink-hi); +} + +h1 { + margin: 0 0 0.75rem; +} + +h2 { + margin: 1.25rem 0 0.5rem; + font-size: 1.45rem; +} + +:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 2px; +} + button { font: inherit; color: var(--ink); @@ -85,24 +107,43 @@ button { border-radius: var(--radius); padding: 0.5rem 0.9rem; min-height: 2.75rem; + cursor: pointer; } button:active { filter: brightness(0.94); } +button.primary { + background: var(--cta-gradient); + color: #011d1c; + border-color: transparent; +} + +button.secondary { + background: transparent; + color: var(--ink); +} + input, select, textarea { font: inherit; color: var(--ink); - background: var(--bg); + background: var(--bg-deep); border: 1px solid var(--line); border-radius: var(--radius); } -input, -select { +label { + display: block; + margin: 0.75rem 0; +} + +/* Every field sits below its label and spans the card's content width. */ +label > input, +label > select, +label > textarea { display: block; width: 100%; margin-top: 0.25rem; @@ -110,18 +151,13 @@ select { min-height: 2.75rem; } -label { - display: block; - margin: 0.75rem 0; -} - .topbar { position: sticky; top: 0; z-index: 5; display: flex; - align-items: baseline; - gap: 0.75rem; + align-items: center; + gap: 0.5rem; padding: 0.6rem 1rem; padding-top: max(0.6rem, env(safe-area-inset-top)); background: var(--bg); @@ -130,8 +166,24 @@ label { white-space: nowrap; } +/* The mono wordmark, like the catalogue's. */ .topbar .brand { - font-weight: 700; + font-family: var(--font-mono); + font-size: 1rem; + letter-spacing: 0.08em; + color: var(--ink); + flex-shrink: 0; +} + +/* The login, host and connection state read as mono chips. */ +.topbar .chip { + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 0.1rem 0.45rem; + border: 1px solid var(--line); + border-radius: var(--radius); + color: var(--muted); + flex-shrink: 0; } .topbar .login, @@ -140,15 +192,13 @@ label { text-overflow: ellipsis; } -.topbar .login { - color: var(--muted); +.topbar .host { + min-width: 0; + flex-shrink: 1; } .topbar .conn { margin-left: auto; - font-size: 0.85rem; - color: var(--muted); - flex-shrink: 0; } .conn-banner { @@ -167,10 +217,17 @@ label { padding: 0.25rem 0.75rem; } +/* The sign-in hero, in the page's display face and one line wide. */ .signin { margin: auto; text-align: center; max-width: 24rem; + padding: 1rem; +} + +.signin h1 { + font-size: clamp(2rem, 9vw, 2.8rem); + white-space: nowrap; } #pair-error:empty { @@ -207,7 +264,7 @@ label { gap: 0.75rem; width: 100%; text-align: left; - background: var(--bg); + background: var(--surface-bg-raise); min-height: 3.5rem; } @@ -282,7 +339,8 @@ label { } .session-title { - font-weight: 600; + font-family: var(--font-mono); + font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -298,23 +356,25 @@ label { display: none; } +/* The terminal card follows the fitted terminal's height (refit writes + * --term-visual-h), capped at 60vh; before the snapshot arrives the + * placeholder holds the pane open. */ .term-pane { background: var(--term-bg); border: 1px solid var(--line); - border-radius: var(--radius); + border-radius: var(--radius-card); overflow: hidden; - min-height: 16rem; - height: 46dvh; } .term-placeholder { color: var(--muted); padding: 1rem; + min-height: 46dvh; } .term-fit { width: 100%; - height: 100%; + height: min(var(--term-visual-h, 46dvh), 60vh); /* Auto so the zoomed terminal pans inside the pane; at zoom 1 the * scaled content fits exactly and no scrollbar appears. Single-finger * pans the xterm viewport natively; pinch reaches the zoom handler @@ -323,7 +383,13 @@ label { touch-action: pan-x pan-y; } +/* The visual box: natural size × the scale refit wrote, clipping the + * transformed host so the terminal scales exactly once. */ .term-scale { + overflow: hidden; +} + +.term-host { transform-origin: top left; } @@ -344,17 +410,18 @@ label { display: none; } +/* Ten keys, all visible: two rows of five equal-width touch targets. */ .keybar { - display: flex; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--gap); - overflow-x: auto; - padding-bottom: 0.25rem; } .keybar .key { - flex: 1 0 auto; - min-width: 3rem; + min-height: 2.75rem; + padding: 0.5rem 0.3rem; font-family: var(--font-mono); + font-size: 0.78rem; } .composer { @@ -407,14 +474,14 @@ label { } .sheet { - background: var(--bg); + background: var(--surface-bg); color: var(--ink); width: 100%; max-width: 48rem; max-height: 92dvh; overflow-y: auto; border: 1px solid var(--line); - border-radius: var(--radius) var(--radius) 0 0; + border-radius: var(--radius-card) var(--radius-card) 0 0; padding: 0 1rem calc(1rem + env(safe-area-inset-bottom)); } @@ -426,14 +493,14 @@ label { align-items: center; justify-content: space-between; gap: 0.75rem; - background: var(--bg); + background: var(--surface-bg); padding: 0.75rem 0; border-bottom: 1px solid var(--line); } .sheet-head h2 { margin: 0; - font-size: 1.1rem; + font-size: 1.3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -453,12 +520,6 @@ label { margin-top: 1rem; } -button.primary { - background: var(--accent); - color: var(--accent-ink); - border-color: transparent; -} - .sheet-error { color: var(--danger); font-size: 0.85rem; @@ -494,7 +555,12 @@ button.primary { .builder-form h3 { margin: 1rem 0 0.5rem; - font-size: 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--surface-ink-dim); } .builder-section-list { @@ -503,10 +569,13 @@ button.primary { padding: 0; } +/* Each section is a raised card; the fields inside span its content + * width (label > input/select/textarea above). */ .builder-section { + background: var(--surface-bg-raise); border: 1px solid var(--line); - border-radius: var(--radius); - padding: 0.5rem 0.75rem; + border-radius: var(--radius-card); + padding: 0.6rem 0.85rem; margin-bottom: var(--gap); } @@ -549,7 +618,7 @@ button.primary { } .builder-preview { - background: var(--key-bg); + background: var(--bg-deep); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.75rem; @@ -565,17 +634,13 @@ button.primary { } @media (min-width: 48rem) { - .term-pane { - height: 60dvh; - } - .sheet-overlay { align-items: center; padding: 1rem; } .sheet { - border-radius: var(--radius); + border-radius: var(--radius-card); } } diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts index 283c286..96dc999 100644 --- a/apps/remote/web/src/terminal.ts +++ b/apps/remote/web/src/terminal.ts @@ -1,17 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // The host PTY on the phone: an xterm.js terminal created once with the // snapshot's cols and rows — the browser never resizes the host — scaled -// with a CSS transform so its full width fits the screen. Pinch or the -// +/− buttons zoom, scrolling stays inside xterm's own viewport, output -// follows the bottom unless the user scrolled up, and tapping the -// terminal focuses xterm's hidden textarea so the soft keyboard opens and -// typed characters come back through onData as input. +// exactly once so its full width fits the pane. .term-host keeps xterm's +// natural pixel size and transform:scale(s) does the shrinking; the +// surrounding .term-scale is the visual box (natural × s) that clips it, +// so the terminal never shrinks twice. Pinch or the +/− buttons zoom that +// fitted scale (the pane then scrolls to pan), output follows the bottom +// unless the user scrolled up, and tapping the terminal focuses xterm's +// hidden textarea so the soft keyboard opens and typed characters come +// back through onData as input. import "@xterm/xterm/css/xterm.css"; import { Terminal } from "@xterm/xterm"; -export type TerminalTheme = "light" | "dark"; - export interface FollowState { following: boolean; } @@ -56,23 +57,18 @@ function touchDistance(a: Touch, b: Touch): number { return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); } -function systemTheme(): TerminalTheme { - if (typeof globalThis.matchMedia === "function") { - return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; - } - return "dark"; -} - -const THEMES = { - dark: { background: "#0c0c0c", foreground: "#e8e6e1", cursor: "#3fb8b0" }, - light: { background: "#faf8f4", foreground: "#1d1510", cursor: "#0e7c76" }, +// The Canopy dark terminal: the deep canvas and surface ink of the page, +// with the teal cursor. One theme — the remote is read in a terminal. +const THEME = { + background: "#011d1c", + foreground: "#edfffe", + cursor: "#3fb8b0", } as const; export function createXtermHandle( host: HTMLElement, cols: number, rows: number, - theme?: TerminalTheme, ): TerminalHandle { const terminal = new Terminal({ cols, @@ -80,7 +76,7 @@ export function createXtermHandle( scrollback: 2000, fontSize: 14, fontFamily: `"Sometype Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`, - theme: { ...THEMES[theme ?? systemTheme()] }, + theme: { ...THEME }, }); terminal.open(host); return { @@ -115,6 +111,7 @@ export class TerminalView { private fit: HTMLElement | null = null; private scaler: HTMLElement | null = null; private host: HTMLElement | null = null; + private pane: HTMLElement | null = null; private handle: TerminalHandle | null = null; private zoom = 1; private following = true; @@ -126,8 +123,9 @@ export class TerminalView { this.callbacks = callbacks; } - // The scaffold: .term-fit (the pane, clips) > .term-scale (the CSS - // transform) > .term-host (xterm's own fixed pixel size). + // The scaffold: .term-pane (sizes the pane) > .term-fit (clips and + // pans) > .term-scale (the visual box) > .term-host (xterm's natural + // pixel size, shrunk by one transform:scale). mount(parent: HTMLElement, cols: number, rows: number): void { this.dispose(); const fit = document.createElement("div"); @@ -142,6 +140,7 @@ export class TerminalView { this.fit = fit; this.scaler = scaler; this.host = host; + this.pane = parent; this.handle = this.factory(host, cols, rows); this.handle.onUserInput((text) => this.callbacks.onUserInput(text)); this.handle.onScroll(() => this.refreshFollow()); @@ -220,9 +219,17 @@ export class TerminalView { } const content = host.firstElementChild ?? host; const scale = computeScale(fit.clientWidth, content.scrollWidth, this.zoom); - scaler.style.transform = `scale(${scale})`; + // One scale, applied once: the host is natural-size and carries the + // transform; the scaler is the visual box (natural × scale) that + // clips it, so the terminal never renders at scale². + host.style.transform = `scale(${scale})`; scaler.style.width = `${content.scrollWidth * scale}px`; scaler.style.height = `${content.scrollHeight * scale}px`; + fit.dataset.scale = `${scale}`; + this.pane?.style.setProperty( + "--term-visual-h", + `${content.scrollHeight * scale}px`, + ); } dispose(): void { @@ -234,6 +241,7 @@ export class TerminalView { this.fit = null; this.scaler = null; this.host = null; + this.pane = null; this.pinchStart = null; this.following = true; } diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index 4528bc7..f7f5626 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -201,6 +201,27 @@ describe("createShell", () => { shell.showSignIn(); const button = query("#signin-go"); expect(button.textContent).toBe("Sign in with GitHub"); + expect(button.classList.contains("primary")).toBe(true); + }); + + it("renders the colophon on sign-in and pair only", () => { + shell.showSignIn(); + expect(root.querySelector("footer.look-colophon")?.textContent).toContain("Canopy remote"); + shell.showPair(); + expect(root.querySelector("footer.look-colophon")).not.toBeNull(); + shell.showHosts(); + expect(root.querySelector("footer.look-colophon")).toBeNull(); + shell.showSessions(); + expect(root.querySelector("footer.look-colophon")).toBeNull(); + shell.showSession("s1", "build"); + expect(root.querySelector("footer.look-colophon")).toBeNull(); + }); + + it("marks the body with the current view", () => { + shell.showSession("s1", "build"); + expect(document.body.dataset.view).toBe("session"); + shell.showSessions(); + expect(document.body.dataset.view).toBe("sessions"); }); it("shows the pair form with the header once logged in", () => { diff --git a/apps/remote/web/test/smoke.spec.ts b/apps/remote/web/test/smoke.spec.ts index 2541860..ce03686 100644 --- a/apps/remote/web/test/smoke.spec.ts +++ b/apps/remote/web/test/smoke.spec.ts @@ -25,6 +25,97 @@ test("renders the terminal and the key bar at 360x640", async ({ page }) => { expect(overflow).toBeLessThanOrEqual(1); }); +test("fits the whole terminal to the pane at 390x844", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + // The pane re-fits on resize: scale = pane width / terminal width. + await expect + .poll(async () => Number(await page.locator(".term-fit").getAttribute("data-scale"))) + .toBeGreaterThan(0.5); + const measured = await page.evaluate(() => { + const pane = document.querySelector(".term-pane") as HTMLElement; + const term = document.querySelector(".xterm") as HTMLElement; + const fit = document.querySelector(".term-fit") as HTMLElement; + return { + pane: pane.getBoundingClientRect(), + term: term.getBoundingClientRect(), + scale: Number(fit.dataset.scale), + fontPx: parseFloat(getComputedStyle(term).fontSize), + }; + }); + // The full 80-column terminal sits inside the pane, scaled exactly once. + expect(measured.term.width).toBeLessThanOrEqual(measured.pane.width + 1); + expect(measured.term.right).toBeLessThanOrEqual(measured.pane.right + 1); + // And the glyphs stay readable: at least 7 CSS px on a 390 px screen. + expect(measured.fontPx * measured.scale).toBeGreaterThanOrEqual(7); + const shot = await page.screenshot({ path: testInfo.outputPath("session-390x844.png") }); + await testInfo.attach("session-390x844", { body: shot, contentType: "image/png" }); +}); + +test("shows all ten keys inside the viewport at 360x640", async ({ page }) => { + const keys = page.locator("#keybar .key"); + await expect(keys).toHaveCount(10); + const boxes = await keys.evaluateAll((els) => + els.map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; + }), + ); + // Two rows of five equal-width keys, every one a touch target in view. + const rows = new Set(boxes.map((box) => Math.round(box.y))); + expect(rows.size).toBe(2); + for (const rowY of rows) { + const row = boxes.filter((box) => Math.round(box.y) === rowY); + expect(row).toHaveLength(5); + for (const box of row) { + expect(Math.abs(box.width - row[0]!.width)).toBeLessThanOrEqual(1); + } + } + for (const box of boxes) { + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.right).toBeLessThanOrEqual(360); + expect(box.bottom).toBeLessThanOrEqual(640); + } +}); + +test("dresses the page in the Canopy surface", async ({ page }) => { + await expect(page.locator("html")).toHaveAttribute("data-surface", "tui"); + const look = await page.evaluate(() => ({ + body: getComputedStyle(document.body).backgroundColor, + view: document.body.dataset.view ?? null, + fondo: (() => { + const host = document.getElementById("fondo"); + return { + mounted: host?.querySelector("canvas") !== null, + opacity: host === null ? null : getComputedStyle(host).opacity, + }; + })(), + })); + // #012624, the Liquid Abyss canvas of the Canopy page. + expect(look.body).toBe("rgb(1, 38, 36)"); + expect(look.view).toBe("session"); + // The honeycomb is behind the session, dimmed so the terminal stays legible. + expect(look.fondo.mounted).toBe(true); + expect(look.fondo.opacity).toBe("0.15"); +}); + +test("puts the Content textarea full width in its card", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator(".composer-builder").click(); + await expect(page.locator("#builder-form")).toBeVisible(); + const measured = await page.evaluate(() => { + const card = document.querySelector(".builder-section") as HTMLElement; + const area = document.querySelector(".section-content") as HTMLTextAreaElement; + const style = getComputedStyle(card); + const contentWidth = + card.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return { contentWidth, areaWidth: area.getBoundingClientRect().width }; + }); + expect(Math.abs(measured.areaWidth - measured.contentWidth)).toBeLessThanOrEqual(1); + const shot = await page.screenshot({ path: testInfo.outputPath("builder-390x844.png") }); + await testInfo.attach("builder-390x844", { body: shot, contentType: "image/png" }); +}); + test("terminal pans when zoomed", async ({ page }) => { await page.locator("#term-zoom-in").click(); await page.locator("#term-zoom-in").click(); diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts index ca41ea6..f3782b8 100644 --- a/apps/remote/web/test/terminal-view.test.ts +++ b/apps/remote/web/test/terminal-view.test.ts @@ -166,4 +166,29 @@ describe("TerminalView", () => { view.dispose(); expect(parent.querySelector(".term-fit")).toBeNull(); }); + + it("scales once into the visual box and reports the scale", () => { + const { parent, view } = setup(); + view.mount(parent, 80, 24); + const fit = parent.querySelector(".term-fit"); + const scaler = parent.querySelector(".term-scale"); + const host = parent.querySelector(".term-host"); + if (!fit || !scaler || !host) { + throw new Error("missing scaffold"); + } + // happy-dom has no layout: pin the measurements refit reads. + Object.defineProperty(fit, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(host, "scrollWidth", { value: 800, configurable: true }); + Object.defineProperty(host, "scrollHeight", { value: 480, configurable: true }); + view.refit(); + // The scale applies once, on the host; the scaler is the visual box + // (natural × scale) and the pane learns the visual height. + expect(scaler.style.transform).toBe(""); + expect(host.style.transform).toBe("scale(0.5)"); + expect(scaler.style.width).toBe("400px"); + expect(scaler.style.height).toBe("240px"); + expect(fit.dataset.scale).toBe("0.5"); + expect(parent.style.getPropertyValue("--term-visual-h")).toBe("240px"); + view.dispose(); + }); }); From da25b6905ed8762b4b2fb6a6eb6b7f0940e92f2c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 27 Oct 2026 04:31:11 -0500 Subject: [PATCH 029/180] style(remote): fit the terminal to the screen, show every key and dress the remote in the Canopy page look (reviewer 2 fixes) --- apps/remote/web/playwright.serve.config.ts | 5 ++- apps/remote/web/test/smoke.spec.ts | 51 ++++++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/remote/web/playwright.serve.config.ts b/apps/remote/web/playwright.serve.config.ts index 56642fa..e74427e 100644 --- a/apps/remote/web/playwright.serve.config.ts +++ b/apps/remote/web/playwright.serve.config.ts @@ -20,8 +20,11 @@ export default defineConfig({ webServer: { // SESSION_SECRET is a Worker secret in prod; locally --var provides the // minimum env /auth/me needs, so it answers its real 401 (no cookie). + // build:look first: the app links /look/tokens.css and the generated + // public/look is gitignored, so without it the serve run would test a + // bare unstyled build (and still pass). command: - "npm run build:remote-web && npx wrangler dev --config ../worker/wrangler.toml --port 8787 --var SESSION_SECRET:dev-serve-secret", + "npm run build:look && npm run build:remote-web && npx wrangler dev --config ../worker/wrangler.toml --port 8787 --var SESSION_SECRET:dev-serve-secret", url: "http://127.0.0.1:8787/", // Own server only: see apps/graphs/playwright.config.ts — a reused server // can be wedged (the health check never answers) or out of date. diff --git a/apps/remote/web/test/smoke.spec.ts b/apps/remote/web/test/smoke.spec.ts index ce03686..58a444e 100644 --- a/apps/remote/web/test/smoke.spec.ts +++ b/apps/remote/web/test/smoke.spec.ts @@ -27,25 +27,58 @@ test("renders the terminal and the key bar at 360x640", async ({ page }) => { test("fits the whole terminal to the pane at 390x844", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - // The pane re-fits on resize: scale = pane width / terminal width. + // The pane re-fits on resize: scale = pane width / terminal width. Wait + // for that re-fit to settle — the fit left over from the 360 px start + // also reports a scale > 0.5, so measuring straight away would race the + // ResizeObserver (the visual box was still the old 326 px wide). await expect - .poll(async () => Number(await page.locator(".term-fit").getAttribute("data-scale"))) - .toBeGreaterThan(0.5); + .poll(async () => + page.evaluate(() => { + const fit = document.querySelector(".term-fit") as HTMLElement; + const scaler = document.querySelector(".term-scale") as HTMLElement; + const rows = document.querySelector(".xterm-rows") as HTMLElement; + if (innerWidth !== 390) { + return Number.MAX_SAFE_INTEGER; + } + return Math.max( + Math.abs(scaler.getBoundingClientRect().width - fit.clientWidth), + Math.abs(rows.getBoundingClientRect().width - fit.clientWidth), + ); + }), + ) + .toBeLessThanOrEqual(2); const measured = await page.evaluate(() => { const pane = document.querySelector(".term-pane") as HTMLElement; - const term = document.querySelector(".xterm") as HTMLElement; + const scaler = document.querySelector(".term-scale") as HTMLElement; + const rows = document.querySelector(".xterm-rows") as HTMLElement; const fit = document.querySelector(".term-fit") as HTMLElement; return { pane: pane.getBoundingClientRect(), - term: term.getBoundingClientRect(), + // The terminal's visual box: natural size × the fitted scale. + visual: scaler.getBoundingClientRect(), + // The text itself carries xterm's own font size (the .xterm box + // inherits the page's), so this is the size the reader sees. + text: rows.getBoundingClientRect(), + fontPx: parseFloat(getComputedStyle(rows).fontSize), scale: Number(fit.dataset.scale), - fontPx: parseFloat(getComputedStyle(term).fontSize), + fitScroll: fit.scrollWidth, + fitClient: fit.clientWidth, }; }); - // The full 80-column terminal sits inside the pane, scaled exactly once. - expect(measured.term.width).toBeLessThanOrEqual(measured.pane.width + 1); - expect(measured.term.right).toBeLessThanOrEqual(measured.pane.right + 1); + // The full 80-column terminal sits inside the pane and fills it, + // scaled exactly once (no phantom horizontal scroll at the fit). + expect(measured.visual.width).toBeLessThanOrEqual(measured.pane.width + 1); + expect(measured.visual.right).toBeLessThanOrEqual(measured.pane.right + 1); + expect(Math.abs(measured.visual.width - measured.fitClient)).toBeLessThanOrEqual(1); + expect(measured.fitScroll - measured.fitClient).toBeLessThanOrEqual(1); + // The text runs to the pane edge and no further: 80 columns inside 390 px. + // Filling the pane (not merely sitting inside it) is what fails when the + // terminal scales twice or not at all. + expect(Math.abs(measured.text.width - measured.fitClient)).toBeLessThanOrEqual(2); + expect(measured.text.width).toBeLessThanOrEqual(measured.pane.width + 1); + expect(measured.text.right).toBeLessThanOrEqual(measured.pane.right + 1); // And the glyphs stay readable: at least 7 CSS px on a 390 px screen. + expect(measured.fontPx).toBeGreaterThanOrEqual(10); expect(measured.fontPx * measured.scale).toBeGreaterThanOrEqual(7); const shot = await page.screenshot({ path: testInfo.outputPath("session-390x844.png") }); await testInfo.attach("session-390x844", { body: shot, contentType: "image/png" }); From 1b58b0d2fe834c49e292a33f9f24c04c0392b437 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 27 Oct 2026 08:17:01 -0500 Subject: [PATCH 030/180] fix(remote): label the New button as New agent so it reads as starting an agent on the host --- apps/remote/web/src/new-dialog.ts | 2 +- apps/remote/web/src/sessions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/remote/web/src/new-dialog.ts b/apps/remote/web/src/new-dialog.ts index 4c6e4fc..fc4f842 100644 --- a/apps/remote/web/src/new-dialog.ts +++ b/apps/remote/web/src/new-dialog.ts @@ -40,7 +40,7 @@ export class NewDialog { constructor(catalog: CatalogStore, callbacks: NewDialogCallbacks) { this.catalog = catalog; this.callbacks = callbacks; - this.sheet = new Sheet({ kind: "new", title: "New session" }, () => this.close()); + this.sheet = new Sheet({ kind: "new", title: "Start an agent on this host" }, () => this.close()); } get isOpen(): boolean { diff --git a/apps/remote/web/src/sessions.ts b/apps/remote/web/src/sessions.ts index cb48fed..5cedb21 100644 --- a/apps/remote/web/src/sessions.ts +++ b/apps/remote/web/src/sessions.ts @@ -101,7 +101,7 @@ export function renderSessions( .join(""); root.innerHTML = `

    Sessions

    ` + - `
    ` + + `` + (items.length === 0 ? `

    No sessions on this host.

    ` : `
      ${rows}
    `); From dd4e63a246d23482e3641df7ab37ed5422140873 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 27 Oct 2026 08:49:05 -0500 Subject: [PATCH 031/180] style(graphs): turn the catalogue into a technical index with datasheet pages and plates drawn in the Canopy page's node colours --- apps/graphs/src/documents.ts | 84 +- apps/graphs/src/official.ts | 55 +- apps/graphs/src/pages/copy.ts | 35 +- apps/graphs/src/pages/layout.ts | 44 +- apps/graphs/src/pages/look.ts | 305 ++- apps/graphs/src/pages/md.ts | 27 +- apps/graphs/src/pages/routes.ts | 264 ++- apps/graphs/src/plate/document.ts | 21 +- apps/graphs/src/plate/layout.ts | 274 ++- apps/graphs/src/plate/render.ts | 163 +- apps/graphs/src/plate/vertical.ts | 396 ++++ apps/graphs/src/plate/vocabulary.ts | 48 + apps/graphs/src/search.ts | 7 + .../test/__snapshots__/pages.test.ts.snap | 1770 ++++++++++++----- .../test/__snapshots__/plate.test.ts.snap | 16 +- apps/graphs/test/analytics.test.ts | 3 + apps/graphs/test/e2e/visual.spec.ts | 66 +- apps/graphs/test/md.test.ts | 36 + apps/graphs/test/pages.test.ts | 182 +- apps/graphs/test/plate.test.ts | 117 +- packages/look/tokens.css | 6 + tests/look.test.ts | 1 + 22 files changed, 2966 insertions(+), 954 deletions(-) create mode 100644 apps/graphs/src/plate/vertical.ts diff --git a/apps/graphs/src/documents.ts b/apps/graphs/src/documents.ts index fcfe22f..dec1e22 100644 --- a/apps/graphs/src/documents.ts +++ b/apps/graphs/src/documents.ts @@ -13,6 +13,7 @@ import type { DocumentKind, JsonValue } from "../../../src/schema.js"; import type { ApiError, GitHub } from "./github.js"; import { isApiError } from "./github.js"; import { decodeBase64, isArray, isNumber, isRecord, isString, parseJson, readJson, stringField } from "./json.js"; +import { harnessOf } from "./plate/document.js"; import { isValidDocument } from "./validate.js"; import { isOfficialRepo } from "./lib.js"; @@ -28,6 +29,12 @@ export interface Doc { repo: string; /** GitHub stars, or null when nobody told us. Never zero-filled. */ stars: number | null; + /** Last updated, `YYYY-MM-DD`, or null when nobody told us. */ + updated: string | null; + /** The distinct harness stems the document runs on, sorted, possibly empty. */ + harnesses: string[]; + /** The commit or branch the page was read from, or null when unknown. */ + sourceRef: string | null; /** Install counts from the Analytics Engine, present only when configured. */ downloads?: number; } @@ -48,6 +55,10 @@ export interface DocumentSource { slug: string; /** Stars when the caller already knows them (a search hit, the repo call). */ stars: number | null; + /** Last updated (`YYYY-MM-DD`) when the caller already knows it. */ + updated?: string | null; + /** The git ref to read at: a branch, a commit, or "" for the default. */ + ref?: string; } /** The document filename, by kind, following the publish convention. */ @@ -201,6 +212,49 @@ function nodeCountOf(kind: DocumentKind, document: JsonValue): number { return document["nodes"].length; } +/** + * The distinct harness stems a validated document runs on, sorted: every + * node's config plus every ensemble member's platform/model. Unknown + * harnesses contribute nothing — the dots only show what the map knows. + */ +function harnessesOf(kind: DocumentKind, document: JsonValue): string[] { + if (!isRecord(document)) { + return []; + } + const found = new Set(); + if (kind === "node") { + const stem = harnessOf(document["config"]); + if (stem !== null) { + found.add(stem); + } + return [...found]; + } + const nodes = document["nodes"]; + if (isArray(nodes)) { + for (const node of nodes) { + const stem = isRecord(node) ? harnessOf(node["config"]) : null; + if (stem !== null) { + found.add(stem); + } + } + } + const ensembles = document["ensembles"]; + if (isArray(ensembles)) { + for (const ensemble of ensembles) { + const members = isRecord(ensemble) ? ensemble["members"] : undefined; + if (isArray(members)) { + for (const member of members) { + const stem = harnessOf(member); + if (stem !== null) { + found.add(stem); + } + } + } + } + } + return [...found].sort(); +} + /** * Load, validate and render one document. Returns `absent` for the ordinary * misses (no folder, no file, README only missing), a typed error when @@ -217,8 +271,9 @@ export async function loadDocument( | ApiError > { const { owner, repo, kind, slug } = source; + const ref = source.ref ?? ""; const jsonPath = `${source.directory}/${documentFile(kind)}`; - const documentText = await readText(github, owner, repo, jsonPath, "", log); + const documentText = await readText(github, owner, repo, jsonPath, ref, log); if (documentText.kind === "absent") { log(`documents: dropped ${owner}/${repo} ${jsonPath}: missing document file`); return { kind: "absent" }; @@ -233,7 +288,7 @@ export async function loadDocument( } const document = parsed.value; const readmePath = `${source.directory}/README.md`; - const readme = await readText(github, owner, repo, readmePath, "", log); + const readme = await readText(github, owner, repo, readmePath, ref, log); const readmeText = readme.kind === "text" ? readme.text : ""; if (readme.kind === "absent") { log(`documents: ${owner}/${repo} ${jsonPath} listed without a README`); @@ -251,6 +306,9 @@ export async function loadDocument( nodeCount: nodeCountOf(kind, document), repo: `${owner}/${repo}`, stars: source.stars, + updated: source.updated ?? null, + harnesses: harnessesOf(kind, document), + sourceRef: ref === "" ? null : ref, }, }, }; @@ -264,3 +322,25 @@ export function starsFromRepository(value: unknown): number | null { } return null; } + +/** A GitHub timestamp as the catalogue shows it: `YYYY-MM-DD`, or null. */ +export function toDateOnly(value: unknown): string | null { + if (!isString(value)) { + return null; + } + return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null; +} + +/** Read `updated_at` from a `/repos/...` body as `YYYY-MM-DD`, null when absent. */ +export function updatedFromRepository(value: unknown): string | null { + return isRecord(value) ? toDateOnly(value["updated_at"]) : null; +} + +/** Read `default_branch` from a `/repos/...` body, "" when absent. */ +export function branchFromRepository(value: unknown): string { + if (!isRecord(value)) { + return ""; + } + const branch = value["default_branch"]; + return isString(branch) ? branch.trim() : ""; +} diff --git a/apps/graphs/src/official.ts b/apps/graphs/src/official.ts index 58448e0..0436317 100644 --- a/apps/graphs/src/official.ts +++ b/apps/graphs/src/official.ts @@ -19,9 +19,11 @@ import { readJson } from "./json.js"; import { type CatalogEntry, type Doc, + branchFromRepository, documentDirectory, loadDocument, starsFromRepository, + updatedFromRepository, } from "./documents.js"; /** Upper bound on documents one listing will render. */ @@ -53,17 +55,32 @@ function listedDirectories(value: unknown): ListedDir[] { }); } -/** The repo body for stars; `null` when GitHub does not tell us. */ -async function officialStars(github: GitHub): Promise { +/** What one `/repos/...` call tells the catalogue: stars, updated, branch. */ +export interface RepoInfo { + stars: number | null; + updated: string | null; + branch: string; +} + +/** The repo body for stars, updated and the default branch; blanks when GitHub stays silent. */ +async function officialRepo(github: GitHub, repository: string): Promise { + const blank: RepoInfo = { stars: null, updated: null, branch: "" }; const response = await github.get( - `https://api.github.com/repos/${OFFICIAL_REPOSITORY}`, + `https://api.github.com/repos/${repository}`, "contents", ); if (isApiError(response)) { - return null; + return blank; } const body = await readJson(response); - return body.ok ? starsFromRepository(body.value) : null; + if (!body.ok) { + return blank; + } + return { + stars: starsFromRepository(body.value), + updated: updatedFromRepository(body.value), + branch: branchFromRepository(body.value), + }; } /** @@ -105,8 +122,8 @@ export async function listOfficial( } directories[kind] = listedDirectories(body.value); } - const stars = await officialStars(github); - if (stars === null) { + const info = await officialRepo(github, OFFICIAL_REPOSITORY); + if (info.stars === null) { log("official: stars unavailable for the listing"); } @@ -125,7 +142,9 @@ export async function listOfficial( directory: documentDirectory(OFFICIAL_OWNER, OFFICIAL_REPO, kind, listed.directory), kind, slug: listed.directory, - stars, + stars: info.stars, + updated: info.updated, + ref: info.branch, }; const loaded = await loadDocument(github, source, log); if (loaded.kind === "doc") { @@ -152,19 +171,27 @@ export async function loadOfficialDocument( | { kind: "absent" } | ApiError > { + const info = await officialRepo(github, OFFICIAL_REPOSITORY); const source = { owner: OFFICIAL_OWNER, repo: OFFICIAL_REPO, directory: documentDirectory(OFFICIAL_OWNER, OFFICIAL_REPO, kind, slug), kind, slug, - stars: null, + stars: info.stars, + updated: info.updated, + ref: info.branch, }; - const loaded = await loadDocument(github, source, log); - if (loaded.kind === "doc" && loaded.entry.doc.stars === null) { - loaded.entry.doc.stars = await officialStars(github); - } - return loaded; + return loadDocument(github, source, log); +} + +/** + * The community repo behind one document page: stars, updated and the + * default branch, blanks when GitHub stays silent. The page reads the + * document at that branch, so the sheet's Ref names a real ref. + */ +export async function communityRepo(github: GitHub, owner: string, repo: string): Promise { + return officialRepo(github, `${owner}/${repo}`); } /** JSON value re-export so callers do not reach past this module. */ diff --git a/apps/graphs/src/pages/copy.ts b/apps/graphs/src/pages/copy.ts index ca959ee..14ba257 100644 --- a/apps/graphs/src/pages/copy.ts +++ b/apps/graphs/src/pages/copy.ts @@ -25,6 +25,12 @@ export const WORDMARK = "graphs"; /** The Canopy page the wordmark links back to. */ export const CANOPY_HREF = "https://univerlab.org/canopy/"; +/** The header's two links: Publish (its own page, its own spec) and Source. */ +export const PUBLISH_HREF = "/publish"; +export const PUBLISH_LABEL = "Publish"; +export const SOURCE_HREF = "https://github.com/UniverLab/canopy-web"; +export const SOURCE_LABEL = "Source"; + /** The search field's visible label. */ export const SEARCH_LABEL = "Search graphs and nodes"; @@ -64,6 +70,7 @@ export const STAR_BUTTON = "Star on GitHub"; /** Counts, shown only when present. */ export const STARS_LABEL = "stars"; export const DOWNLOADS_LABEL = "downloads"; +export const NODES_LABEL = "nodes"; /** The install section and the two command prefixes. */ export const INSTALL_HEADING = "Install"; @@ -105,9 +112,20 @@ export const COLOPHON_EXP = "EXP-001"; export const COLOPHON_LINKS: Array<{ label: string; href: string }> = [ { label: "Canopy", href: CANOPY_HREF }, { label: "univerlab.org", href: "https://univerlab.org/" }, - { label: "Source", href: "https://github.com/UniverLab/canopy-web" }, + { label: SOURCE_LABEL, href: SOURCE_HREF }, ]; +/** The datasheet's field labels, in sheet order. */ +export const SHEET_KIND = "Kind"; +export const SHEET_NAME = "Name"; +export const SHEET_SOURCE = "Source"; +export const SHEET_REF = "Ref"; +export const SHEET_STARS = "Stars"; +export const SHEET_NODES = "Nodes"; +export const SHEET_EDGES = "Edges"; +export const SHEET_ENSEMBLES = "Ensembles"; +export const SHEET_AGENTS = "Agent nodes"; + /** The calm 503: GitHub (or its rate limit) failed the data layer. */ export const BUSY_HEADING = "GitHub is busy"; export const BUSY_BODY = "The catalogue could not reach GitHub right now. Please try again in a moment."; @@ -138,3 +156,18 @@ export function llmsLine(doc: Doc, ref: string): string { export function countLine(label: string, count: number): string { return `${count} ${label}`; } + +/** How a graph's nodes read by kind: "agent 2 · check 1 · gate 2". */ +export function nodesByKindLine(counts: Array<{ kind: string; count: number }>): string { + return counts.map(({ kind, count }) => `${kind} ${count}`).join(" · "); +} + +/** How a graph's edges read, with the fail-edge count: "5 edges · 1 fail". */ +export function edgesLine(total: number, fail: number): string { + return `${total} edges · ${fail} fail`; +} + +/** How one ensemble reads: "reviewers (parallel, 2 members)". */ +export function ensembleLine(name: string, strategy: string, members: number): string { + return `${name} (${strategy}, ${members} members)`; +} diff --git a/apps/graphs/src/pages/layout.ts b/apps/graphs/src/pages/layout.ts index 8b38316..dc76aec 100644 --- a/apps/graphs/src/pages/layout.ts +++ b/apps/graphs/src/pages/layout.ts @@ -5,17 +5,25 @@ * A route builds a `Page` — a title, a meta description, the HTML body and * its markdown twin — and `page()` answers the request with one or the * other: a request sending `Accept: text/markdown` gets the twin (content - * type `text/markdown`), everything else the full HTML document dressed in - * the shared look kit of the Canopy page: the deep-teal surface with the - * live honeycomb behind a centred column, the lab's three typefaces, the - * mono wordmark, and the UniverLab colophon as footer. + * type `text/markdown`), everything else the full HTML document. The shell + * is the technical index around the content: a header band with the mono + * wordmark, a full-width search form and the Publish/Source links over the + * live honeycomb (mounted inside the band only), a left-aligned content + * column below, and the UniverLab colophon as footer. */ import { CANOPY_HREF, COLOPHON_EXP, COLOPHON_LINKS, COLOPHON_PRODUCT, + PUBLISH_HREF, + PUBLISH_LABEL, + SEARCH_BUTTON, + SEARCH_LABEL, + SEARCH_PLACEHOLDER, SITE_NAME, + SOURCE_HREF, + SOURCE_LABEL, TITLE_SEPARATOR, WORDMARK, } from "./copy.js"; @@ -37,7 +45,19 @@ export function wantsMarkdown(req: Request): boolean { return accept.includes("text/markdown"); } -function htmlDocument(page: Page): string { +/** The header band's search form, holding the current query when there is one. */ +function searchForm(q: string): string { + return ( + `` + ); +} + +function htmlDocument(q: string, page: Page): string { const title = escapeHtml(`${page.title}${TITLE_SEPARATOR}${SITE_NAME}`); const description = escapeHtml(page.description); return [ @@ -54,10 +74,19 @@ function htmlDocument(page: Page): string { ``, ``, ``, - fondoHost(), `
    `, ``, `
    `, page.html, @@ -92,7 +121,8 @@ export function page(req: Request, content: Page, status = 200): Response { }, }); } - return new Response(htmlDocument(content), { + const q = (new URL(req.url).searchParams.get("q") ?? "").slice(0, 256); + return new Response(htmlDocument(q, content), { status, headers: { "content-type": "text/html; charset=utf-8", diff --git a/apps/graphs/src/pages/look.ts b/apps/graphs/src/pages/look.ts index b395dee..a58d05f 100644 --- a/apps/graphs/src/pages/look.ts +++ b/apps/graphs/src/pages/look.ts @@ -1,14 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 /** - * The catalogue's dress: the shared look kit of the Canopy page. + * The catalogue's dress: the shared look kit of the Canopy page, worn as a + * technical index instead of an editorial page. * * The kit (packages/look, copied to public/look by `npm run build:look`) * owns the tokens, the three self-hosted typefaces, the colophon and the * honeycomb background. This module only glues them to a server-rendered * string page: three stylesheet links, the page stylesheet over the kit's - * variables, the hidden canvas host, the bootstrap for `fondo.js` and the - * two inline scripts (copy buttons, background mount). No framework and no - * build step of its own: what this module returns is what the page ships. + * variables, the honeycomb host inside the header band, the bootstrap for + * `fondo.js` and the two inline scripts (copy buttons, background mount). + * No framework and no build step of its own: what this module returns is + * what the page ships. + * + * Type roles: Sometype Mono is the UI face (labels, meta, buttons, tabs, + * rows, the sheet), Old Standard TT only names documents (row titles, page + * title), EB Garamond only sets README prose and descriptions. */ import { COPY_ARIA, COPY_BUTTON, COPY_DONE } from "./copy.js"; import { escapeHtml } from "./md.js"; @@ -28,19 +34,21 @@ export function lookLinks(): string { } /** - * The honeycomb's host: a fixed canvas layer behind the whole page. It is - * decoration, so it is hidden from assistive tech and from the tab order; - * the canvas `fondo.js` appends inherits both. + * The honeycomb's host: it lives inside the header band and the background + * bundle fills it. It is decoration, so it is hidden from assistive tech + * and from the tab order; the canvas `fondo.js` appends inherits both. */ export function fondoHost(): string { return ``; } /** - * The page stylesheet, inline, over the kit's tokens. Mobile first: the - * base rules are the 390px column and the one `min-width` query widens the - * gutters on desktop. The plate figure scrolls inside itself so the page - * never does. + * The page stylesheet, inline, over the kit's tokens. The register is a + * parts catalogue: dense mono rows under a header band, the honeycomb only + * behind the band, the body flat `--surface-bg`. Nothing is centred except + * the plate inside its figure. Breakpoints: 640 px (band height, page + * gutters, plate orientation), 900 px (index rows), 1024 px (datasheet + * columns). */ export function lookCss(): string { return ` @@ -54,18 +62,41 @@ body { min-height: 100vh; background: var(--surface-bg); color: var(--surface-ink); - font-family: var(--font-body); - font-size: 1.0625rem; - line-height: 1.6; + font-family: var(--font-mono); + font-size: 0.9375rem; + line-height: 1.55; -webkit-text-size-adjust: 100%; } a { color: var(--teal); text-underline-offset: 0.15em; } a:hover { text-decoration-thickness: 2px; } :focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; } -.fondo { position: fixed; inset: 0; z-index: 0; pointer-events: none; } -.look-shell { position: relative; z-index: 1; } +.look-shell { min-height: 100vh; } -.site-header { max-width: 46rem; margin: 0 auto; padding: 1.25rem 1.25rem 0; } +.site-header { + position: relative; + height: 120px; + overflow: hidden; + border-bottom: 1px solid var(--surface-line); +} +.fondo { position: absolute; inset: 0; z-index: 0; pointer-events: none; } +.band { + position: relative; + z-index: 1; + max-width: 1120px; + height: 100%; + margin: 0 auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.6rem; +} +.band-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} .wordmark { display: inline-block; padding-bottom: 0.15rem; @@ -77,19 +108,59 @@ a:hover { text-decoration-thickness: 2px; } text-decoration: none; } .wordmark:hover { border-bottom-color: var(--teal); } +.site-nav { + display: flex; + gap: 1rem; + font-family: var(--font-mono); + font-size: 0.82rem; +} +.site-nav a { color: var(--surface-ink-dim); text-decoration: none; } +.site-nav a:hover { color: var(--surface-ink); text-decoration: underline; text-decoration-color: var(--teal); } -main.look-col { max-width: 46rem; margin: 0 auto; padding: 1.25rem 1.25rem 3.5rem; } - -h1, h2, h3 { - font-family: var(--font-display); - font-weight: 400; - line-height: 1.2; - color: var(--ink-hi); +.search { display: flex; gap: 0.5rem; margin: 0; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; } -h1 { margin: 0 0 0.75rem; font-size: clamp(1.9rem, 6vw, 2.6rem); } -h2 { margin: 2rem 0 0.65rem; font-size: 1.45rem; } -h3 { - margin: 1.5rem 0 0.5rem; +.search input { + flex: 1; + min-width: 0; + padding: 0.55rem 0.7rem; + background: var(--bg-deep); + color: var(--surface-ink); + border: 1px solid var(--surface-line); + border-radius: var(--radius-ctl); + font-family: var(--font-mono); + font-size: 0.9rem; +} +.search button { + flex: none; + padding: 0.55rem 1rem; + background: transparent; + border: 1px solid var(--surface-line); + border-radius: var(--radius-ctl); + color: var(--surface-ink); + font-family: var(--font-mono); + font-size: 0.85rem; + cursor: pointer; +} +.search button:hover { border-color: var(--teal); } + +main.look-col { max-width: 1120px; margin: 0 auto; padding: 1.5rem 1rem 3.5rem; } + +h1, h2, h3 { font-weight: 400; line-height: 1.25; color: var(--ink-hi); } +h1 { margin: 0 0 1rem; font-family: var(--font-mono); font-size: 1.05rem; } +.index-title { font-family: var(--font-mono); font-size: 1.05rem; margin: 0 0 1rem; } +.doc-title { font-family: var(--font-display); font-size: clamp(1.7rem, 5vw, 2.3rem); margin: 0 0 0.5rem; } +.kicker { + margin: 0; font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; @@ -97,30 +168,11 @@ h3 { text-transform: uppercase; color: var(--surface-ink-dim); } +h2.kicker { margin: 2rem 0 0.75rem; } +h3.kicker { margin: 1.25rem 0 0.5rem; } p { margin: 0 0 1rem; } -.description { margin: 0 0 1.25rem; font-size: 1.15rem; color: var(--surface-ink-dim); } -.hero-title { max-width: 22ch; } +.description { margin: 0 0 1rem; font-family: var(--font-body); font-size: 1.1rem; color: var(--surface-ink-dim); } -.search { display: grid; gap: 0.6rem; margin: 0 0 1.5rem; } -.search label { - display: block; - margin-bottom: 0.35rem; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--surface-ink-dim); -} -.search input { - width: 100%; - padding: 0.6rem 0.7rem; - background: var(--bg-deep); - color: var(--surface-ink); - border: 1px solid var(--surface-line); - border-radius: var(--radius-ctl); - font-family: var(--font-body); - font-size: 1rem; -} .tabs { display: flex; flex-wrap: wrap; @@ -139,29 +191,66 @@ p { margin: 0 0 1rem; } } .tabs a[aria-current="page"] { border-bottom-color: var(--teal); color: var(--surface-ink); } -.doc-list { display: grid; gap: 0.75rem; list-style: none; padding: 0; margin: 0; } -.doc-list li { - padding: 1rem 1.1rem; - background: var(--surface-bg-raise); - border: 1px solid var(--surface-line); - border-radius: var(--radius-card); +ol.index { + list-style: none; + margin: 0 0 1.5rem; + padding: 0; + border-top: 1px solid var(--surface-line); } -.doc-list .doc-name { +li.row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.75rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--surface-line); + font-family: var(--font-mono); + font-size: 0.85rem; +} +.row-name { font-family: var(--font-display); - font-size: 1.2rem; + font-size: 1.15rem; color: var(--surface-ink); text-decoration: none; } -.doc-list .doc-name:hover { text-decoration: underline; text-decoration-color: var(--teal); } -.doc-list .doc-description { display: block; margin-top: 0.25rem; color: var(--surface-ink-dim); } -.doc-stats { - margin-left: 0.5rem; +.row-name:hover { text-decoration: underline; text-decoration-color: var(--teal); } +.kind { + padding: 0.05rem 0.4rem; + border: 1px solid var(--surface-line); + border-radius: var(--radius-ctl); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--surface-ink-dim); +} +.row-meta { color: var(--surface-ink-dim); white-space: nowrap; } +.dots { display: inline-flex; gap: 0.3rem; align-self: center; } +.dot { + display: inline-block; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--nc, var(--canopy)); +} +.row-install { display: inline-flex; gap: 0.5rem; align-items: center; margin-left: auto; min-width: 0; } +.row-install code { + padding: 0.35rem 0.6rem; + background: var(--bg-deep); + border: 1px solid var(--surface-line); + border-radius: var(--radius-ctl); + color: var(--surface-ink); font-family: var(--font-mono); font-size: 0.78rem; - color: var(--surface-ink-faint); + line-height: 1.5; white-space: nowrap; + overflow-x: auto; +} +.row-desc { + flex-basis: 100%; + font-family: var(--font-body); + font-size: 1rem; + color: var(--surface-ink-dim); } -.meta { font-family: var(--font-mono); font-size: 0.85rem; color: var(--surface-ink-dim); } .button { display: inline-block; @@ -177,36 +266,42 @@ p { margin: 0 0 1rem; } cursor: pointer; } .button:hover { filter: brightness(1.05); } - -.install { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: stretch; margin: 0 0 1.25rem; } -.install code { - flex: 1 1 12rem; - min-width: 0; - padding: 0.55rem 0.7rem; - background: var(--surface-bg-raise); - border: 1px solid var(--surface-line); - border-radius: var(--radius-ctl); - color: var(--surface-ink); - font-family: var(--font-mono); - font-size: 0.82rem; - line-height: 1.5; - white-space: nowrap; - overflow-x: auto; -} .copy { - padding: 0.5rem 0.85rem; + flex: none; + padding: 0.35rem 0.7rem; background: transparent; border: 1px solid var(--surface-line); border-radius: var(--radius-ctl); color: var(--surface-ink); font-family: var(--font-mono); - font-size: 0.82rem; + font-size: 0.78rem; cursor: pointer; } .copy:hover { border-color: var(--teal); } +.doc-cols { display: flex; flex-direction: column; gap: 1.5rem; margin: 1rem 0 1.5rem; } +dl.sheet { margin: 0; font-family: var(--font-mono); font-size: 0.85rem; } +.sheet .field { + display: grid; + grid-template-columns: 7.5rem minmax(0, 1fr); + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--surface-line); +} +.sheet .field:first-child { border-top: 1px solid var(--surface-line); } +.sheet dt { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--surface-ink-dim); +} +.sheet dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.sheet .row-install { margin-left: 0; width: 100%; } +.sheet .row-install code { flex: 1; min-width: 0; } + figure.plate { - margin: 1rem 0 1.5rem; + margin: 0 0 1.5rem; padding: 0.75rem; max-width: 100%; overflow-x: auto; @@ -214,10 +309,21 @@ figure.plate { border: 1px solid var(--surface-line); border-radius: var(--radius-card); } -.plate svg { display: block; height: auto; max-width: none; } +.doc-cols figure.plate { margin: 0; } +.plate svg { display: block; height: auto; margin: 0 auto; } +.plate .plate-v { display: none; } +.readme { font-family: var(--font-body); font-size: 1.05rem; line-height: 1.6; } +.readme h1, .readme h2, .readme h3 { + font-family: var(--font-body); + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: var(--ink-hi); +} .readme h1 { font-size: 1.6rem; } -.readme h2 { font-size: 1.25rem; } +.readme h2 { font-size: 1.3rem; } +.readme h3 { font-size: 1.1rem; } .readme pre { padding: 0.75rem; background: var(--bg-deep); @@ -229,13 +335,26 @@ figure.plate { } .readme code { font-family: var(--font-mono); font-size: 0.9em; } .readme img { max-width: 100%; height: auto; } -.agent-nodes { font-family: var(--font-mono); font-size: 0.9rem; color: var(--surface-ink-dim); } .busy { padding: 0.25rem 0 0.25rem 1rem; border-left: 3px solid var(--teal); } -@media (min-width: 48rem) { - .site-header { padding: 2rem 2rem 0; } - main.look-col { padding: 2rem 2rem 4rem; } - .search { grid-template-columns: minmax(0, 1fr) auto; align-items: end; } +@media (max-width: 639px) { + .plate .plate-h { display: none; } + .plate .plate-v { display: block; } +} +@media (max-width: 899px) { + .row-desc { order: 1; } + .row-install { order: 2; flex-basis: 100%; margin-left: 0; } + .row-install code { flex: 1; } +} +@media (min-width: 640px) { + .site-header { height: 160px; } + .band { padding: 0 1.5rem; } + main.look-col { padding: 2rem 1.5rem 4rem; } +} +@media (min-width: 1024px) { + .doc-cols { flex-direction: row; align-items: flex-start; } + .doc-cols figure.plate { flex: 1; min-width: 0; } + dl.sheet { flex: 0 0 320px; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } @@ -245,9 +364,9 @@ figure.plate { /** * The background bootstrap, inline after `/look/fondo.js`: it mounts the - * honeycomb on the hidden host with the kit's asset base. It waits for - * DOMContentLoaded when the deferred bundle has not run yet, so the order - * of the two script tags does not matter. + * honeycomb on the host inside the header band with the kit's asset base. + * It waits for DOMContentLoaded when the deferred bundle has not run yet, + * so the order of the two script tags does not matter. */ export function fondoScript(): string { return ( diff --git a/apps/graphs/src/pages/md.ts b/apps/graphs/src/pages/md.ts index aa2da95..5803c46 100644 --- a/apps/graphs/src/pages/md.ts +++ b/apps/graphs/src/pages/md.ts @@ -2,8 +2,10 @@ /** * A minimal markdown renderer for READMEs, safe by construction. * - * Supported: headings, lists, paragraphs, fenced code blocks, links and - * inline code — nothing else. Everything the renderer does not recognise is + * Supported: headings, lists, paragraphs, fenced code blocks, links, + * inline code and `**strong**`/`__strong__`/`*em*`/`_em_` emphasis — + * nothing else. Intraword underscores stay literal and code spans are never + * emphasized. Everything the renderer does not recognise is * escaped, so HTML in a README never passes through as HTML; the only tags * in the output are the ones this file emits. Links carry `rel="nofollow"` * and only http(s), relative and anchor targets survive; anything else @@ -27,13 +29,30 @@ const SAFE_URL = /^(?:https?:\/\/|\/|#|\.\.?\/)/i; /** Inline pieces this renderer understands, scanned left to right. */ const INLINE_RE = /(`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g; +/** + * Inline emphasis, applied to already-escaped text (so `*` and `_` are still + * literal while `<`, `>` and `&` are entities). Strong first, then emphasis; + * `*` pairs anywhere with a non-empty run, while `_` pairs only at word + * boundaries — an underscore with a word character on its outer side stays + * literal, so `snake_case_name` never emphasizes. Code spans never reach + * here: the inline scanner pulls them out before this runs. + */ +function renderEmphasis(escaped: string): string { + const strong = escaped + .replace(/\*\*([^*\n]+?)\*\*/g, "$1") + .replace(/(?$1"); + return strong + .replace(/\*([^*\n]+?)\*/g, "$1") + .replace(/(?$1"); +} + function renderInline(raw: string): string { let html = ""; let cursor = 0; for (const match of raw.matchAll(INLINE_RE)) { const piece = match[0]; const start = match.index ?? 0; - html += escapeHtml(raw.slice(cursor, start)); + html += renderEmphasis(escapeHtml(raw.slice(cursor, start))); cursor = start + piece.length; if (piece.startsWith("`")) { html += `${escapeHtml(piece.slice(1, -1))}`; @@ -48,7 +67,7 @@ function renderInline(raw: string): string { html += renderInline(label); } } - return html + escapeHtml(raw.slice(cursor)); + return html + renderEmphasis(escapeHtml(raw.slice(cursor))); } const HEADING_RE = /^(#{1,6})\s+(.*)$/; diff --git a/apps/graphs/src/pages/routes.ts b/apps/graphs/src/pages/routes.ts index cd14171..672d17b 100644 --- a/apps/graphs/src/pages/routes.ts +++ b/apps/graphs/src/pages/routes.ts @@ -22,49 +22,49 @@ import { type Log, createGitHub, isApiError } from "../github.js"; import { OFFICIAL_OWNER, OFFICIAL_REPO, OWNER_RE, REPO_RE, SLUG_RE } from "../lib.js"; import { structureOf, type GraphDocument, type NodeDocument } from "../plate/document.js"; import { renderNodePlate, renderPlate } from "../plate/render.js"; +import { harnessColor } from "../plate/vocabulary.js"; import type { DocumentKind, JsonValue } from "../../../../src/schema.js"; import type { Doc } from "../documents.js"; import { documentDirectory, loadDocument } from "../documents.js"; -import { listOfficial, loadOfficialDocument } from "../official.js"; +import { communityRepo, listOfficial, loadOfficialDocument } from "../official.js"; import { type SearchSort, searchCommunity } from "../search.js"; import { isArray, isRecord, isString, stringField } from "../json.js"; import { - AGENT_NODES_HEADING, - AGENT_NODES_NOTE, BUSY_BODY, BUSY_HEADING, - DOWNLOADS_LABEL, - DRAWING_HEADING, EMPTY_OFFICIAL_GRAPHS, EMPTY_OFFICIAL_NODES, GRAPHS_HEADING, INSTALL_HEADING, LLMS_HEADER, NEW_TAB, - NO_RESULTS, NODES_HEADING, + NODES_LABEL, + NO_RESULTS, NOT_FOUND_BODY, NOT_FOUND_HEADING, OFFICIAL_HEADING, README_HEADING, - REPO_LABEL, - REF_LABEL, - SEARCH_BUTTON, - SEARCH_LABEL, - SEARCH_PLACEHOLDER, + SHEET_AGENTS, + SHEET_EDGES, + SHEET_ENSEMBLES, + SHEET_KIND, + SHEET_NAME, + SHEET_NODES, + SHEET_REF, + SHEET_SOURCE, + SHEET_STARS, SITE_NAME, STARS_LABEL, STAR_BUTTON, - STRUCTURE_ENSEMBLES_LABEL, - STRUCTURE_HEADING, - STRUCTURE_NODES_LABEL, - STRUCTURE_NONE, - STRUCTURE_ROUTES_LABEL, TAGLINE, TOP_TAB, countLine, + edgesLine, + ensembleLine, installCommand, llmsLine, + nodesByKindLine, resultsFor, } from "./copy.js"; import { page, type Page } from "./layout.js"; @@ -155,17 +155,6 @@ function agentNames(document: unknown): string[] { ); } -function searchForm(q: string): string { - return ( - `` - ); -} - function searchHref(q: string, sort: SearchSort): string { const base = q === "" ? "?" : `?q=${encodeURIComponent(q)}&`; return `/search${base}sort=${sort}`; @@ -177,18 +166,45 @@ function tabs(q: string, active: SearchSort | null): string { return ``; } -function docItem(doc: Doc): string { - const stats: string[] = []; - if (doc.stars !== null) { - stats.push(countLine(STARS_LABEL, doc.stars)); - } - if (doc.downloads !== undefined) { - stats.push(countLine(DOWNLOADS_LABEL, doc.downloads)); +/** The harness dots of an index row: one accent dot per known harness. */ +function harnessDots(harnesses: string[]): string { + if (harnesses.length === 0) { + return ""; } - const meta = stats.length === 0 ? "" : ` ${escapeHtml(stats.join(" · "))}`; + const dots = harnesses.map((stem) => { + const color = harnessColor(stem) ?? "var(--canopy)"; + return ``; + }); + return `${dots.join("")}`; +} + +/** + * One index row: the name in the display face, the kind badge, the node + * count for graphs, harness dots, stars, last updated and the install box. + * One flex line on desktop, three stacked lines under 900 px; the + * description rides along as the row's second block. + */ +function docItem(doc: Doc): string { + const install = installCommand(doc.kind, docRef(doc).ref); + const nodes = doc.kind === "graph" ? `${countLine(NODES_LABEL, doc.nodeCount)}` : ""; + const stars = doc.stars === null ? "" : `${escapeHtml(countLine(STARS_LABEL, doc.stars))}`; + const updated = doc.updated === null + ? "" + : ``; + const description = doc.description === "" + ? "" + : `${escapeHtml(doc.description)}`; return ( - `
  • ${escapeHtml(doc.name)}${meta}` + - `${escapeHtml(doc.description)}
  • ` + `
  • ` + + `${escapeHtml(doc.name)}` + + `${escapeHtml(doc.kind)}` + + nodes + + harnessDots(doc.harnesses) + + stars + + updated + + `${escapeHtml(install)}${copyButton(install)}` + + description + + `
  • ` ); } @@ -196,7 +212,7 @@ function docList(docs: Doc[], emptyLine: string): string { if (docs.length === 0) { return `

    ${emptyLine}

    `; } - return `
      ${docs.map(docItem).join("")}
    `; + return `
      ${docs.map(docItem).join("")}
    `; } /** Backslash the markdown punctuation one line of text may carry. */ @@ -214,70 +230,121 @@ function docListMarkdown(docs: Doc[], emptyLine: string): string { return `${lines.join("\n")}\n`; } -/** The plate figure for a document page: the graph plate, or the one node. */ +/** + * The plate figure for a document page: the graph plate, or the one node — + * rendered in both orientations, with CSS showing the vertical one under + * 640 px and the horizontal one above. No JavaScript is involved. + */ function plateFigure(document: JsonValue, kind: PageRef["kind"]): string { // The data layer only serves validated documents, so the cast is honest; // the renderers stay total and draw nothing when it is not. - const plate = kind === "graph" - ? renderPlate(document as unknown as GraphDocument) - : renderNodePlate(document as unknown as NodeDocument); - return plate === "" ? "" : `

    ${DRAWING_HEADING}

    ${plate}
    `; + const horizontal = kind === "graph" + ? renderPlate(document as unknown as GraphDocument, "horizontal") + : renderNodePlate(document as unknown as NodeDocument, "horizontal"); + const vertical = kind === "graph" + ? renderPlate(document as unknown as GraphDocument, "vertical") + : renderNodePlate(document as unknown as NodeDocument, "vertical"); + return horizontal === "" && vertical === "" + ? "" + : `
    ${horizontal}${vertical}
    `; +} + +/** One datasheet field: the label and its already-escaped HTML value. */ +function sheetField(label: string, value: string): string { + return `
    ${escapeHtml(label)}
    ${value}
    `; } -/** The structure section of a graph's markdown twin: nodes and routes as text. */ -function structureSection(document: JsonValue): string { +/** A graph's node counts by kind, in first-appearance order. */ +function nodesByKind(document: JsonValue): Array<{ kind: string; count: number }> { const structure = structureOf(document); if (structure === null) { - return ""; - } - const nodes = structure.nodes.length === 0 - ? STRUCTURE_NONE - : structure.nodes.map((node) => `${node.name} (${node.kind})`).join(", "); - const lines = [`## ${STRUCTURE_HEADING}`, ``, `${STRUCTURE_NODES_LABEL}: ${nodes}.`, ``]; - if (structure.ensembles.length > 0) { - const ensembles = structure.ensembles - .map((ensemble) => `${ensemble.name} (${ensemble.strategy}, ${ensemble.members} members)`) - .join(", "); - lines.push(`${STRUCTURE_ENSEMBLES_LABEL}: ${ensembles}.`, ``); + return []; } - if (structure.routes.length > 0) { - lines.push( - `${STRUCTURE_ROUTES_LABEL}:`, - ``, - ...structure.routes.map((route) => `- ${route.from} --${route.via}--> ${route.to}`), - ``, - ); + const counts = new Map(); + for (const node of structure.nodes) { + counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1); } - return `${lines.join("\n")}\n`; + return [...counts].map(([kind, count]) => ({ kind, count })); } -/** One document page: name, meta, star button, install, drawing, README. */ -function documentPage(doc: Doc, document: JsonValue, ref: PageRef): Page { +/** + * The datasheet beside the plate: kind, name, source, ref and stars, then — + * for graphs — nodes by kind, edges with the fail count, ensembles and the + * agent nodes a harness must run, then install. A field with no data is + * omitted, never printed as a placeholder. + */ +function datasheet(doc: Doc, document: JsonValue, ref: PageRef): { html: string; lines: string[] } { const install = installCommand(ref.kind, ref.ref); - const stats: string[] = []; + const fields: string[] = [ + sheetField(SHEET_KIND, escapeHtml(ref.kind)), + sheetField(SHEET_NAME, escapeHtml(doc.name)), + sheetField(SHEET_SOURCE, `${escapeHtml(doc.repo)}`), + ]; + const lines = [ + `- ${SHEET_KIND}: ${ref.kind}`, + `- ${SHEET_NAME}: ${mdText(doc.name)}`, + `- ${SHEET_SOURCE}: ${ref.repoUrl}`, + ]; + if (doc.sourceRef !== null) { + fields.push(sheetField(SHEET_REF, escapeHtml(doc.sourceRef))); + lines.push(`- ${SHEET_REF}: ${doc.sourceRef}`); + } if (doc.stars !== null) { - stats.push(countLine(STARS_LABEL, doc.stars)); + fields.push(sheetField(SHEET_STARS, escapeHtml(countLine(STARS_LABEL, doc.stars)))); + lines.push(`- ${SHEET_STARS}: ${countLine(STARS_LABEL, doc.stars)}`); } - if (doc.downloads !== undefined) { - stats.push(countLine(DOWNLOADS_LABEL, doc.downloads)); + if (ref.kind === "graph") { + const kinds = nodesByKind(document); + if (kinds.length > 0) { + fields.push(sheetField(SHEET_NODES, escapeHtml(nodesByKindLine(kinds)))); + lines.push(`- ${SHEET_NODES}: ${nodesByKindLine(kinds)}`); + } + const structure = structureOf(document); + if (structure !== null) { + const fail = structure.routes.filter((route) => route.via === "fail").length; + fields.push(sheetField(SHEET_EDGES, escapeHtml(edgesLine(structure.routes.length, fail)))); + lines.push(`- ${SHEET_EDGES}: ${edgesLine(structure.routes.length, fail)}`); + } + const ensembles = structure?.ensembles ?? []; + if (ensembles.length > 0) { + const text = ensembles + .map((ensemble) => ensembleLine(ensemble.name, ensemble.strategy, ensemble.members)) + .join(", "); + fields.push(sheetField(SHEET_ENSEMBLES, escapeHtml(text))); + lines.push(`- ${SHEET_ENSEMBLES}: ${mdText(text)}`); + } + const agents = agentNames(document); + if (agents.length > 0) { + fields.push(sheetField(SHEET_AGENTS, escapeHtml(agents.join(", ")))); + lines.push(`- ${SHEET_AGENTS}: ${agents.join(", ")}`); + } } - const meta = stats.length === 0 ? "" : ` ${escapeHtml(stats.join(" · "))}`; - const agents = ref.kind === "graph" ? agentNames(document) : []; + fields.push( + sheetField( + INSTALL_HEADING, + `${escapeHtml(install)}${copyButton(install)}`, + ), + ); + return { html: `
    ${fields.join("")}
    `, lines }; +} + +/** One document page: title, star button, plate beside its datasheet, README. */ +function documentPage(doc: Doc, document: JsonValue, ref: PageRef): Page { + const install = installCommand(ref.kind, ref.ref); + const sheet = datasheet(doc, document, ref); + const readme = doc.readme.trim() === "" + ? "" + : `

    ${README_HEADING}

    \n
    ${renderMarkdown(doc.readme)}
    `; const html = [ - `

    ${escapeHtml(doc.name)}

    `, + `

    ${escapeHtml(doc.name)}

    `, `

    ${escapeHtml(doc.description)}

    `, - `

    ${escapeHtml(doc.repo)}${meta}

    `, `

    ${STAR_BUTTON}

    `, - `

    ${INSTALL_HEADING}

    `, - `
    ${escapeHtml(install)}${copyButton(install)}
    `, + `
    `, plateFigure(document, ref.kind), - agents.length === 0 - ? "" - : `

    ${AGENT_NODES_HEADING}

    ${escapeHtml(agents.join(", "))}

    ` + - `

    ${AGENT_NODES_NOTE}

    `, - `

    ${README_HEADING}

    `, - `
    ${renderMarkdown(doc.readme)}
    `, + sheet.html, + `
    `, + readme, ].join("\n"); const markdown = [ @@ -285,21 +352,15 @@ function documentPage(doc: Doc, document: JsonValue, ref: PageRef): Page { ``, doc.description, ``, - `- ${REPO_LABEL}: ${ref.repoUrl}`, - `- ${REF_LABEL}: ${ref.ref}`, - ...stats.map((stat) => `- ${stat}`), + ...sheet.lines, ``, `## ${INSTALL_HEADING}`, ``, ` ${install}`, ``, - ref.kind === "graph" ? structureSection(document) : "", - agents.length === 0 + readme === "" ? "" - : `## ${AGENT_NODES_HEADING}\n\n${agents.join(", ")}\n\n${AGENT_NODES_NOTE}\n`, - `## ${README_HEADING}`, - ``, - doc.readme.endsWith("\n") || doc.readme === "" ? doc.readme : `${doc.readme}\n`, + : `## ${README_HEADING}\n\n${doc.readme.endsWith("\n") ? doc.readme : `${doc.readme}\n`}`, ].join("\n"); return { title: doc.name, description: doc.description, html, markdown }; @@ -307,13 +368,12 @@ function documentPage(doc: Doc, document: JsonValue, ref: PageRef): Page { function homePage(graphs: Doc[], nodes: Doc[]): Page { const html = [ - `

    ${escapeHtml(TAGLINE)}

    `, - searchForm(""), + `

    ${escapeHtml(TAGLINE)}

    `, tabs("", null), - `

    ${OFFICIAL_HEADING}

    `, - `

    ${GRAPHS_HEADING}

    `, + `

    ${OFFICIAL_HEADING}

    `, + `

    ${GRAPHS_HEADING}

    `, docList(graphs, EMPTY_OFFICIAL_GRAPHS), - `

    ${NODES_HEADING}

    `, + `

    ${NODES_HEADING}

    `, docList(nodes, EMPTY_OFFICIAL_NODES), ].join("\n"); const markdown = [ @@ -336,8 +396,7 @@ function homePage(graphs: Doc[], nodes: Doc[]): Page { function searchPage(q: string, sort: SearchSort, items: Doc[]): Page { const heading = resultsFor(q); const html = [ - `

    ${escapeHtml(heading)}

    `, - searchForm(q), + `

    ${escapeHtml(heading)}

    `, tabs(q, sort), items.length === 0 ? `

    ${NO_RESULTS}

    ` : docList(items, NO_RESULTS), ].join("\n"); @@ -356,7 +415,9 @@ async function documentResponse(req: Request, env: PageEnv, deps: PageDeps, ref: const { github, log, volumes } = readers(env, deps); const loaded = ref.official ? await loadOfficialDocument(github, ref.kind, ref.slug, log) - : await loadDocument( + : await (async () => { + const info = await communityRepo(github, ref.owner, ref.repo); + return loadDocument( github, { owner: ref.owner, @@ -364,10 +425,13 @@ async function documentResponse(req: Request, env: PageEnv, deps: PageDeps, ref: directory: documentDirectory(ref.owner, ref.repo, ref.kind, ref.slug), kind: ref.kind, slug: ref.slug, - stars: null, + stars: info.stars, + updated: info.updated, + ref: info.branch, }, log, ); + })(); // The load result is a tagged union, not an ApiError: "doc" wins first. if (loaded.kind === "absent") { return notFound(req); diff --git a/apps/graphs/src/plate/document.ts b/apps/graphs/src/plate/document.ts index 79c1e24..2278c85 100644 --- a/apps/graphs/src/plate/document.ts +++ b/apps/graphs/src/plate/document.ts @@ -8,7 +8,7 @@ */ import { isArray, isNumber, isRecord, isString } from "../json.js"; import type { JsonValue } from "../../../../src/schema.js"; -import type { Kind } from "./vocabulary.js"; +import { harnessStem, type Kind } from "./vocabulary.js"; /** A graph document as `renderPlate` takes it: the registry's format v6. */ export interface GraphDocument { @@ -68,6 +68,8 @@ export interface NodeView { docKind: string; position: number; commitRights: boolean; + /** The canonical harness stem of the node's platform/model, if it names one. */ + harness: string | null; } /** One parsed ensemble: its strategy, member labels and wiring. */ @@ -76,6 +78,8 @@ export interface EnsembleView { strategy: Strategy; minPass: number; members: string[]; + /** Per member, the canonical harness stem — same order as `members`. */ + memberHarness: Array; entryFrom: string | null; entryCondition: JsonValue; extraEntries: Array<{ from: string; condition: JsonValue }>; @@ -133,6 +137,18 @@ function strategyOf(kind: unknown): Strategy { return kind === "cascade" || kind === "round_robin" ? kind : "parallel"; } +/** + * The harness a node config or ensemble member runs on: the canonical stem + * of its `platform`, else its `model` — null when neither names a known + * harness. Platform wins, the same priority the member label uses. + */ +export function harnessOf(config: unknown): string | null { + if (!isRecord(config)) { + return null; + } + return harnessStem(config["platform"]) ?? harnessStem(config["model"]); +} + /** A member chip label: its try order, plus its platform or model when known. */ function memberLabel(member: unknown, index: number): string { const numeral = String(index + 1); @@ -196,6 +212,7 @@ export function parseDocument(document: unknown): ParsedGraph | null { docKind: isString(kind) ? kind : "check", position: isNumber(position) ? position : 0, commitRights: isRecord(config) && config["commit_rights"] === true, + harness: harnessOf(config), }); } const edges: EdgeView[] = []; @@ -216,6 +233,7 @@ export function parseDocument(document: unknown): ParsedGraph | null { const members = isArray(rawMembers) ? rawMembers.map((member, index) => memberLabel(member, index)) : []; + const memberHarness = isArray(rawMembers) ? rawMembers.map((member) => harnessOf(member)) : []; const extraEntries: Array<{ from: string; condition: JsonValue }> = []; const rawExtra = ensemble["extra_entry_sources"]; if (isArray(rawExtra)) { @@ -230,6 +248,7 @@ export function parseDocument(document: unknown): ParsedGraph | null { strategy: strategyOf(ensemble["kind"]), minPass: isNumber(ensemble["min_pass"]) ? ensemble["min_pass"] : members.length, members, + memberHarness, entryFrom: targetId(ensemble["entry_from_node"]), entryCondition: ensemble["entry_condition"] ?? null, extraEntries, diff --git a/apps/graphs/src/plate/layout.ts b/apps/graphs/src/plate/layout.ts index 36bc2f3..51f0596 100644 --- a/apps/graphs/src/plate/layout.ts +++ b/apps/graphs/src/plate/layout.ts @@ -23,45 +23,49 @@ import { type EdgeClass, type EnsembleView, type NodeView, + type ParsedGraph, type Strategy, } from "./document.js"; import { GEOM, TYPE, edgePath, type Kind, type Pt } from "./vocabulary.js"; +/** The plate's two orientations: ranks left to right, or top to bottom. */ +export type Orientation = "horizontal" | "vertical"; + /** Constant horizontal step between ranks (viewBox units). */ export const RANK_DX = 26; /** Vertical slot one main-lane node occupies. */ const NODE_SLOT = 16; -/** Vertical step between member chips inside a group. */ -const MEMBER_DY = 7; +/** Vertical step between member chips inside a group. Shared with vertical.ts. */ +export const MEMBER_DY = 7; /** Member chip size. */ const MEMBER_W = 14; const MEMBER_H = 4; -/** Head room (two label lines) and foot room inside a group outline. */ -const GROUP_HEAD = 9; -const GROUP_FOOT = 4; +/** Head room (two label lines) and foot room inside a group outline. Shared with vertical.ts. */ +export const GROUP_HEAD = 9; +export const GROUP_FOOT = 4; /** Widest a group outline may grow: it must clear the neighbouring ranks. */ const GROUP_W_MAX = 38; -/** Gap between the main lane and the infra lane. */ -const LANE_GAP = 20; -/** ViewBox padding. */ -const PAD_X = 10; -const PAD_TOP = 10; -const PAD_BOTTOM = 10; -/** Extra arc height per same-row fail edge into one target. */ -const LIFT_STEP = 4; +/** Gap between the main lane and the infra lane. Shared with vertical.ts. */ +export const LANE_GAP = 20; +/** ViewBox padding. Shared with vertical.ts. */ +export const PAD_X = 10; +export const PAD_TOP = 10; +export const PAD_BOTTOM = 10; +/** Extra arc height per same-row fail edge into one target. Shared with vertical.ts. */ +export const LIFT_STEP = 4; /** Node labels longer than this are truncated; the shape keeps the full name. */ export const LABEL_MAX = 16; /** Ensemble names longer than this are truncated in the group label. */ const GROUP_NAME_MAX = 20; /** Pixels per viewBox unit for the svg width/height attributes. */ export const PLATE_SCALE = 5; -/** Mono advance at the node-label type size, in viewBox units. */ -const LABEL_CHAR = 2.3 * 0.6; -/** Mono advance at the group/member type size, in viewBox units. */ -const GROUP_CHAR = TYPE.harness * 0.6; +/** Mono advance at the node-label type size, in viewBox units. Shared with vertical.ts. */ +export const LABEL_CHAR = 2.3 * 0.6; +/** Mono advance at the group/member type size, in viewBox units. Shared with vertical.ts. */ +export const GROUP_CHAR = TYPE.harness * 0.6; /** One layout unit: a node or an ensemble, with its document order. */ -interface Unit { +export interface Unit { id: string; order: number; node: NodeView | null; @@ -72,6 +76,8 @@ export interface PlacedNode { name: string; shape: Kind; docKind: string; + /** The node's harness stem for `--nc` and the harness label, if it names one. */ + harness: string | null; x: number; y: number; w: number; @@ -82,6 +88,8 @@ export interface PlacedNode { export interface PlacedMember { label: string; + /** The member's harness stem for `--nc` and the harness label, if known. */ + harness: string | null; x: number; y: number; } @@ -107,6 +115,7 @@ export interface PlacedEdge { export interface PlateLayout { name: string; + orientation: Orientation; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[]; @@ -120,16 +129,16 @@ export interface PlateLayout { } /** Round to two decimals so paths print stably. */ -function num(value: number): number { +export function num(value: number): number { return Math.round(value * 100) / 100; } /** Round every decimal in a path string: midpoints print without float noise. */ -function smooth(d: string): string { +export function smooth(d: string): string { return d.replace(/-?\d+\.\d+/g, (match) => String(num(Number(match)))); } -function groupSubtitle(strategy: Strategy, minPass: number, count: number): string { +export function groupSubtitle(strategy: Strategy, minPass: number, count: number): string { if (strategy === "cascade") { return `CASCADE · ${count}`; } @@ -139,16 +148,34 @@ function groupSubtitle(strategy: Strategy, minPass: number, count: number): stri return `PARALLEL · ${minPass}/${count}`; } -function groupTitle(name: string): string { +export function groupTitle(name: string): string { const clipped = name.length > GROUP_NAME_MAX ? `${name.slice(0, GROUP_NAME_MAX - 1)}…` : name; return clipped.toUpperCase(); } -function groupWidth(title: string, subtitle: string, members: string[]): number { +export function groupWidth(title: string, subtitle: string, members: string[]): number { const chars = Math.max(title.length, subtitle.length, ...members.map((m) => m.length)); return Math.min(GROUP_W_MAX, Math.max(MEMBER_W + 4, chars * GROUP_CHAR + 6)); } +/** + * Half the width of a node's drawn labels: the name below the shape, or the + * harness line under it when that runs wider. Both centre on the node, so + * the viewBox padding stays symmetric and the drawing centred. + */ +export function labelHalfOf(name: string, harness: string | null): number { + const nameHalf = (Math.min(name.length, LABEL_MAX + 1) * LABEL_CHAR) / 2; + const harnessHalf = harness === null ? 0 : (harness.length * GROUP_CHAR) / 2; + return Math.max(nameHalf, harnessHalf); +} + +/** The lowest point of a node: its harness line when it has one, else its label. */ +export function nodeBottom(y: number, harness: string | null): number { + return harness === null + ? y + TYPE.labelDy + TYPE.label / 2 + : y + TYPE.harnessDy + TYPE.harness / 2; +} + /** * Longest-path ranks over the rank edges: a Kahn sweep assigns each unit * one rank past its deepest processed predecessor, and when a cycle stalls @@ -186,11 +213,33 @@ function ranksOf(units: Unit[], rankEdges: Array<{ from: string; to: string }>): return rank; } -export function layoutPlate(document: unknown): PlateLayout | null { - const parsed = parseDocument(document); - if (parsed === null) { - return null; - } +/** One rank edge: the forward step from one layout unit to the next. */ +export interface RankEdge { + from: string; + to: string; +} + +/** + * A parsed graph ranked for layout: its units, the rank edges between them, + * each unit's rank, the barycenter-ordered units per rank, the entry node + * and the name-to-unit resolver. Both orientations rank identically; only + * the geometry after this differs. + */ +export interface RankedGraph { + units: Unit[]; + rankEdges: RankEdge[]; + rankOf: Map; + byRank: Map; + entry: NodeView | null; + unitId: (name: string) => string | null; +} + +/** + * Rank a parsed graph: units in document order, longest-path ranks over the + * rank edges, one barycenter sweep within each rank. Deterministic and + * total — the same input ranks the same way for both orientations. + */ +export function rankUnits(parsed: ParsedGraph): RankedGraph { const nodeNames = new Set(parsed.nodes.map((node) => node.name)); const ensembleNames = new Set(parsed.ensembles.map((ensemble) => ensemble.name)); const unitId = (name: string): string | null => { @@ -218,7 +267,7 @@ export function layoutPlate(document: unknown): PlateLayout | null { ]; // Rank edges: node pass/always/route edges, ensemble entries of those // classes, and every ensemble on_pass_to exit. Fail and error never rank. - const rankEdges: Array<{ from: string; to: string }> = []; + const rankEdges: RankEdge[] = []; for (const edge of parsed.edges) { if (!isRankCondition(edge.condition)) { continue; @@ -300,6 +349,91 @@ export function layoutPlate(document: unknown): PlateLayout | null { list.forEach((unit, index) => indexOf.set(unit.id, index)); byRank.set(rank, list); } + return { units, rankEdges, rankOf, byRank, entry, unitId }; +} + +/** + * The token's chain: from the entry, the first outgoing pass edge in + * document order, until it dead-ends or loops back. Centres come from the + * placed layout, so both orientations walk the same chain. + */ +export function passChain( + parsed: ParsedGraph, + entry: NodeView | null, + centres: Map, +): Pt[] { + if (entry === null) { + return []; + } + const nodeNames = new Set(parsed.nodes.map((node) => node.name)); + const chain: Pt[] = []; + const seen = new Set(); + let current: string | null = entry.name; + const first = centres.get(current); + if (first !== undefined) { + chain.push(first); + seen.add(current); + } + const passOut = new Map(); + for (const edge of parsed.edges) { + if ((edge.condition === "pass" || edge.condition === "always" || isRankCondition(edge.condition)) && + !passOut.has(edge.from) && nodeNames.has(edge.from) && nodeNames.has(edge.to)) { + passOut.set(edge.from, edge.to); + } + } + for (const ensemble of parsed.ensembles) { + if (ensemble.entryFrom !== null && !ensemble.entryFrom.startsWith("ensemble:") && + !passOut.has(ensemble.entryFrom) && isRankCondition(ensemble.entryCondition)) { + passOut.set(ensemble.entryFrom, ensemble.name); + } + if (ensemble.onPassTo !== null && !ensemble.onPassTo.startsWith("ensemble:") && + nodeNames.has(ensemble.onPassTo)) { + passOut.set(ensemble.name, ensemble.onPassTo); + } + } + while (current !== null) { + const next: string | null = passOut.get(current) ?? null; + if (next === null || seen.has(next)) { + break; + } + const point = centres.get(next); + if (point === undefined) { + break; + } + chain.push(point); + seen.add(next); + current = next; + } + return chain; +} + +/** + * The token's path along a chain: one leg per step through the + * orientation's own edge curve, at 0.9 seconds a step. Null when the chain + * is too short to walk. + */ +export function passWalk( + chain: Pt[], + leg: (a: Pt, b: Pt) => string, +): { walk: string; seconds: number } | null { + if (chain.length < 2) { + return null; + } + const legs = chain.slice(1).map((point, index) => + smooth(leg(chain[index] as Pt, point)).replace(/^M[^C]+/, "") + ); + return { + walk: `M${chain[0]?.x} ${chain[0]?.y}${legs.join("")}`, + seconds: num((chain.length - 1) * 0.9), + }; +} + +export function layoutPlate(document: unknown): PlateLayout | null { + const parsed = parseDocument(document); + if (parsed === null) { + return null; + } + const { byRank, entry, rankOf, unitId } = rankUnits(parsed); // Geometry in design coordinates: constant rank spacing, stacked ranks, // the infra node skipped in the main lane, members stacked in the group. @@ -330,6 +464,7 @@ export function layoutPlate(document: unknown): PlateLayout | null { name: unit.node.name, shape, docKind: unit.node.docKind, + harness: unit.node.harness, x, y, w: GEOM[shape].w, @@ -337,7 +472,7 @@ export function layoutPlate(document: unknown): PlateLayout | null { rank, infra: false, }); - mainBottom = Math.max(mainBottom, y + TYPE.labelDy + TYPE.label / 2); + mainBottom = Math.max(mainBottom, nodeBottom(y, unit.node.harness)); } else if (unit.ensemble !== null) { const title = groupTitle(unit.ensemble.name); const subtitle = groupSubtitle( @@ -352,6 +487,7 @@ export function layoutPlate(document: unknown): PlateLayout | null { atY.set(unit.id, y); const members = unit.ensemble.members.map((label, index) => ({ label, + harness: unit.ensemble?.memberHarness[index] ?? null, x, y: num(y - h / 2 + GROUP_HEAD + index * MEMBER_DY + MEMBER_DY / 2), })); @@ -373,6 +509,7 @@ export function layoutPlate(document: unknown): PlateLayout | null { name: node.name, shape, docKind: node.docKind, + harness: node.harness, x, y, w: GEOM[shape].w, @@ -503,12 +640,12 @@ export function layoutPlate(document: unknown): PlateLayout | null { maxY = Math.max(maxY, y1); }; for (const node of nodes) { - const labelHalf = (Math.min(node.name.length, LABEL_MAX + 1) * LABEL_CHAR) / 2; + const labelHalf = labelHalfOf(node.name, node.harness); span( Math.min(node.x - node.w / 2, node.x - labelHalf), Math.max(node.x + node.w / 2, node.x + labelHalf), node.y - node.h / 2, - node.y + TYPE.labelDy + TYPE.label / 2, + nodeBottom(node.y, node.harness), ); } for (const group of groups) { @@ -549,64 +686,18 @@ export function layoutPlate(document: unknown): PlateLayout | null { d: smooth(edgePath(shift(edge.a), shift(edge.b), edge.cls === "fail", lifts.get(edge) ?? 0)), })); - // The token walks the main pass chain: from the entry, the first outgoing - // pass edge in document order, until it dead-ends or loops back. - let walk: string | null = null; - let walkSeconds = 0; - if (entry !== null) { - const chain: Pt[] = []; - const seen = new Set(); - let current: string | null = entry.name; - const centres = new Map(); - for (const node of nodes) { - centres.set(node.name, { x: node.x, y: node.y }); - } - for (const group of groups) { - centres.set(group.name, { x: group.x, y: group.y }); - } - const first = centres.get(current); - if (first !== undefined) { - chain.push(first); - seen.add(current); - } - const passOut = new Map(); - for (const edge of parsed.edges) { - if ((edge.condition === "pass" || edge.condition === "always" || isRankCondition(edge.condition)) && - !passOut.has(edge.from) && nodeNames.has(edge.from) && nodeNames.has(edge.to)) { - passOut.set(edge.from, edge.to); - } - } - for (const ensemble of parsed.ensembles) { - if (ensemble.entryFrom !== null && !ensemble.entryFrom.startsWith("ensemble:") && - !passOut.has(ensemble.entryFrom) && isRankCondition(ensemble.entryCondition)) { - passOut.set(ensemble.entryFrom, ensemble.name); - } - if (ensemble.onPassTo !== null && !ensemble.onPassTo.startsWith("ensemble:") && - nodeNames.has(ensemble.onPassTo)) { - passOut.set(ensemble.name, ensemble.onPassTo); - } - } - while (current !== null) { - const next: string | null = passOut.get(current) ?? null; - if (next === null || seen.has(next)) { - break; - } - const point = centres.get(next); - if (point === undefined) { - break; - } - chain.push(point); - seen.add(next); - current = next; - } - if (chain.length > 1) { - const legs = chain.slice(1).map((point, index) => - smooth(edgePath(chain[index] as Pt, point, false)).replace(/^M[^C]+/, "") - ); - walk = `M${chain[0]?.x} ${chain[0]?.y}${legs.join("")}`; - walkSeconds = num((chain.length - 1) * 0.9); - } + // The token walks the main pass chain through this orientation's curves. + const centres = new Map(); + for (const node of nodes) { + centres.set(node.name, { x: node.x, y: node.y }); + } + for (const group of groups) { + centres.set(group.name, { x: group.x, y: group.y }); } + const walked = passWalk( + passChain(parsed, entry, centres), + (a, b) => edgePath(a, b, false), + ); const order = [...nodes] .sort((a, b) => a.rank - b.rank || a.y - b.y || (a.name < b.name ? -1 : 1)) @@ -614,13 +705,14 @@ export function layoutPlate(document: unknown): PlateLayout | null { return { name: parsed.name, + orientation: "horizontal", nodes, groups, edges, width: num(maxX - minX + PAD_X * 2), height: num(maxY - minY + PAD_TOP + PAD_BOTTOM), - walk, - walkSeconds, + walk: walked?.walk ?? null, + walkSeconds: walked?.seconds ?? 0, order, }; } diff --git a/apps/graphs/src/plate/render.ts b/apps/graphs/src/plate/render.ts index dbf166b..23502e8 100644 --- a/apps/graphs/src/plate/render.ts +++ b/apps/graphs/src/plate/render.ts @@ -2,21 +2,43 @@ /** * The plate renderer: a laid-out graph becomes one self-contained `` * in the Canopy vocabulary — node kinds as shapes, pass edges as teal - * S-curves, fail edges as red dashed arcs above the row, ensembles as - * dashed group outlines, the infra node in a lower lane, and a walking - * token along the main pass chain. + * S-curves, fail edges as red dashed arcs above the row (left of the column + * in the vertical orientation), ensembles as dashed group outlines, the + * infra node in a lower lane, and a walking token along the main pass chain. + * + * Node colours follow univerlab's GraphMorph: the shape fills with its + * harness colour at 22% over the deep (`--nc`, the Canopy teal when the + * harness is unknown), chips stay dark with a dim stroke, checks and gates + * dark with a pale stroke, commits filled teal. A node or member with a + * known platform or model family carries its harness stem under its label + * and its `--nc` accent; unknown harnesses draw no label. Nothing in the + * plate ever fills white. * * The svg carries its own ` -
    -

    A catalogue of Canopy graphs and nodes.

    - +

    A catalogue of Canopy graphs and nodes.

    -

    Official

    -

    Graphs

    -
    • hello-graph 7 starsFive nodes with one fail edge, for the drawing test.
    -

    Nodes

    -
    • hello-node 7 starsExample catalogue node: drafts an implementation plan.
    +

    Official

    +

    Graphs

    +
    1. hello-graphgraph5 nodes7 starscanopy graph install hello-graphFive nodes with one fail edge, for the drawing test.
    +

    Nodes

    +
    1. hello-nodenode7 starscanopy node install hello-nodeExample catalogue node: drafts an implementation plan.