diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a15d857 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.idea + +node_modules/ +dist/ +*.log + +profile/* +!profile/default/ +profile/default/* +!profile/default/.gitkeep diff --git a/README.md b/README.md index 8d8f289..fec4dd8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,292 @@ # cloakfetch-gateway -使用篡改的浏览器内核,给爬虫程序提供 真实浏览器伪装 \ No newline at end of file +CloakFetch Gateway 是局域网内使用的浏览器网络执行服务。其他局域网机器可以通过 HTTP API 调用真实 CloakBrowser Chromium 发起请求,并通过 Web Console 观察代理、Cookie、缓存、重定向、页面加载和 Chromium 内部网络事件。 + +它不是 HTTP/SOCKS 代理,也不是通用爬虫框架。当前只保留 GET document navigation,不提供 raw HTTP 引擎,不支持 image/fetch 专用模式。 + +## 运行 + +```powershell +npm install +npx cloakbrowser install +$env:CLOAKFETCH_HOST = "0.0.0.0" +$env:CLOAKFETCH_HEADLESS = "false" +$env:CLOAKFETCH_CONCURRENCY = "4" +$env:CLOAKFETCH_PROXY = "http://127.0.0.1:7890" +npm run dev +``` + +生产构建和运行: + +```powershell +npm run build +npm run start +``` + +局域网访问控制台: + +```text +http://<服务器局域网IP>:9230/console +``` + +服务不会自动获取或猜测服务器局域网 IP。 + +## 配置 + +```text +CLOAKFETCH_HOST=0.0.0.0 +CLOAKFETCH_PORT=9230 +CLOAKFETCH_PROFILE=profile +CLOAKFETCH_PROXY=http://127.0.0.1:7890 +CLOAKFETCH_HEADLESS=false +CLOAKFETCH_CONCURRENCY=4 +CLOAKFETCH_TIMEOUT_MS=30000 +CLOAKFETCH_MAX_BODY_MB=64 +CLOAKFETCH_HISTORY_LIMIT=1000 +CLOAKFETCH_WS_HEARTBEAT_MS=15000 +CLOAKFETCH_NETWORK_HISTORY_LIMIT=10000 +CLOAKFETCH_NETWORK_BODY_CAPTURE_BYTES=1048576 +``` + +`CLOAKFETCH_HOST` 默认是 `0.0.0.0`,也可以设置为 `127.0.0.1` 或具体 IPv4/IPv6 地址。 + +默认 source profile 路径是: + +```text +profile/sources/ +``` + +所有项目内部路径都相对于项目根目录,不依赖启动时的当前工作目录。`profile/` 是运行数据,不应打包、上传或提交。 + +当前 CloakBrowser 版本在部分平台会把带认证代理转成 Chromium 命令行参数,因此 `CLOAKFETCH_PROXY` 中包含 username/password 时会在启动阶段被拒绝,错误码为 `proxy_auth_not_supported`。建议把 Mihomo 或其他上游代理暴露成本地无认证端口,例如 `http://127.0.0.1:7890`。 + +## 基础 API + +`GET /health` + +```json +{ "status": "ok" } +``` + +`POST /v1/fetch` + +```json +{ + "source_id": "default", + "url": "https://example.com/file.png", + "headers": { + "Referer": "https://example.com/" + }, + "timeout_ms": 30000, + "cache_mode": "default" +} +``` + +未传 `source_id` 时使用 `default` SourceSession。响应状态码来自目标站,响应体是目标二进制内容。额外响应头: + +```text +x-cloakfetch-final-url +x-cloakfetch-fetch-mode +``` + +`cache_mode`: + +- `default`:允许 Chromium 使用正常缓存语义。 +- `reload`:禁用当前页面缓存。 +- `/v1/diagnostics/fetch` 会强制使用 `reload`,避免缓存影响代理和目标站诊断。 + +调用方传入的 headers 只应用到第一次主文档导航请求。页面后续 script、style、image、iframe、xhr、fetch、websocket 等请求完全由 Chromium 自己生成。 + +响应大小会先检查 `Content-Length`;没有 `Content-Length` 的响应只能在读取完成后检查实际大小,当前版本不支持真正流式限速。 + +`POST /v1/diagnostics/fetch` + +返回 JSON 诊断信息,不返回二进制 body: + +```json +{ + "url": "https://example.com/file.jpg", + "final_url": "https://example.com/file.jpg", + "status": 200, + "ok": true, + "content_type": "image/jpeg", + "body_size": 44891, + "elapsed_ms": 421, + "fetch_mode": "document", + "error": null +} +``` + +`GET /v1/stats` + +```json +{ + "lifetime_total": 12, + "lifetime_failed": 1, + "active": 0, + "queued": 0 +} +``` + +## SourceSession API + +`default` SourceSession 会自动存在。每个 SourceSession 拥有独立 BrowserContext、独立 profile、独立 Cookie、缓存、LocalStorage、IndexedDB 和 Service Worker。 + +`POST /v1/sources` + +```json +{ + "id": "arcgis", + "warmup_url": "https://www.arcgis.com/", + "locale": "zh-CN", + "viewport": { + "width": 1920, + "height": 1080 + } +} +``` + +`POST /v1/sources/:id/warmup` + +```json +{ + "url": "https://www.arcgis.com/", + "wait_until": "domcontentloaded", + "timeout_ms": 30000 +} +``` + +其他 source API: + +```text +GET /v1/sources +GET /v1/sources/:id +DELETE /v1/sources/:id +GET /v1/sources/:id/cookies +GET /v1/sources/:id/pages +``` + +`source_id` 只用于路径名称映射,只允许字母、数字、点、下划线和短横线。 + +## Network Observer + +Chromium 内部网络事件使用独立 ring buffer,和外层 `/v1/fetch` 请求历史分开。默认保留最近 10000 条,可通过 `CLOAKFETCH_NETWORK_HISTORY_LIMIT` 调整。 + +记录字段包括: + +```text +id, source_id, page_id, request_id, parent_request_id, +started_at, finished_at, duration_ms, +url, method, resource_type, navigation, frame_url, initiator, +request_headers, request_body_size, request_post_data, +status, status_text, protocol, response_headers, response_body_size, +mime_type, remote_address, from_cache, service_worker, +failure_text, redirect_from, redirect_to +``` + +请求头、响应头和 post data 默认完整记录。`CLOAKFETCH_NETWORK_BODY_CAPTURE_BYTES` 只限制观测存储大小,不限制真实请求。 + +```text +0 = 不抓取 body 内容,只记录大小 +-1 = 不限制抓取大小 +``` + +Network API: + +```text +GET /v1/network/recent +GET /v1/network/:id +GET /v1/network/:id/body +GET /v1/network/summary +DELETE /v1/network/history +``` + +`GET /v1/network/recent` 支持: + +```text +limit, source_id, page_id, method, resource_type, status, host, failed, from_cache, text +``` + +`GET /v1/network/summary` 返回 retained、active、finished、failed、method/resource/status/host/protocol 分布、cache hit、字节数和耗时统计。 + +`DELETE /v1/network/history` 只清空观测历史,不关闭 BrowserContext。 + +## WebSocket + +`GET /v1/ws` + +```js +const ws = new WebSocket("ws://127.0.0.1:9230/v1/ws"); +ws.onmessage = event => console.log(JSON.parse(event.data)); +``` + +消息格式: + +```json +{ + "version": 1, + "sequence": 123, + "type": "network.finished", + "time": "2026-07-24T12:00:00.000Z", + "data": {} +} +``` + +事件类型: + +```text +hello +heartbeat +request +source.created +source.starting +source.ready +source.failed +source.removed +page.created +page.closed +network.request +network.response +network.finished +network.failed +browser.console +browser.pageerror +browser.crash +``` + +不提供 SSE,不提供 Socket.IO。 + +## Web Console + +构建后由 Fastify 托管: + +```powershell +npm run build +npm run start +``` + +访问: + +```text +http://127.0.0.1:9230/console +http://<服务器局域网IP>:9230/console +``` + +控制台包含: + +- Runtime 状态和代理状态 +- Source Sessions 状态、Warmup、Remove、Open pages、View cookies +- 外层 Request Log +- Network Inspector +- Network 过滤、详情、Pause live update、Clear local view、清空服务端 network history + +Network WebSocket 事件在前端按 150ms 批量合并,前端最多保留 5000 条 network record。 + +开发模式: + +```powershell +npm run dev +npm run web:dev +``` + +如果 `web/dist` 不存在,`/console` 返回 `console_not_built` 的 404 JSON,不影响 `/health` 和 `/v1/*` API。 diff --git a/cloakfetch-gateway.zip b/cloakfetch-gateway.zip new file mode 100644 index 0000000..12e1dc3 Binary files /dev/null and b/cloakfetch-gateway.zip differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e154865 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3029 @@ +{ + "name": "cloakfetch-gateway", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloakfetch-gateway", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@fastify/static": "^10.1.2", + "@fastify/websocket": "^11.3.0", + "cloakbrowser": "^0.5.1", + "fastify": "^5.10.0", + "playwright-core": "^1.61.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vite": "^8.1.5" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz", + "integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/websocket": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^6.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "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/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cloakbrowser": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/cloakbrowser/-/cloakbrowser-0.5.1.tgz", + "integrity": "sha512-GdRvMrNVWbOGczo8FMaS3rMGws7kNrSiJHQYIjczzAGG8OiHhANx0kBKEKL3roPCITulzqArxqeLfvKO+oYgDQ==", + "license": "MIT", + "dependencies": { + "tar": "^7.0.0" + }, + "bin": { + "cloakbrowser": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "mmdb-lib": ">=2.0.0", + "playwright-core": ">=1.53.0", + "puppeteer-core": ">=21.0.0", + "socks-proxy-agent": ">=10.0.0" + }, + "peerDependenciesMeta": { + "mmdb-lib": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "puppeteer-core": { + "optional": true + }, + "socks-proxy-agent": { + "optional": true + } + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "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": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "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/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar": { + "version": "7.5.21", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", + "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "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/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "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/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "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/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0186753 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "cloakfetch-gateway", + "version": "1.0.0", + "description": "基于 CloakBrowser 的本地浏览器请求转换与下载网关", + "main": "dist/main.js", + "scripts": { + "dev": "tsx src/main.ts", + "web:dev": "vite --config web/vite.config.ts", + "web:build": "vite build --config web/vite.config.ts", + "build:server": "tsc", + "build": "npm run build:server && npm run web:build", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit && tsc -p web/tsconfig.json --noEmit", + "test": "node --import tsx --test \"test/**/*.test.ts\"" + }, + "repository": { + "type": "git", + "url": "http://192.168.31.120:3000/psc/cloakfetch-gateway.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@fastify/static": "^10.1.2", + "@fastify/websocket": "^11.3.0", + "cloakbrowser": "^0.5.1", + "fastify": "^5.10.0", + "playwright-core": "^1.61.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vite": "^8.1.5" + } +} diff --git a/profile/default/.gitkeep b/profile/default/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/profile/default/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3e9b8ff --- /dev/null +++ b/src/config.ts @@ -0,0 +1,214 @@ +import net from "node:net"; +import { resolveProfilePath } from "./paths.js"; +import type { AppConfig, ProxyConfig } from "./types.js"; +import { HttpError } from "./types.js"; + +const DEFAULT_HOST = "0.0.0.0"; +const DEFAULT_PORT = 9230; +const DEFAULT_PROFILE = "profile"; +const DEFAULT_HEADLESS = false; +const DEFAULT_CONCURRENCY = 4; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_BODY_MB = 64; +const DEFAULT_HISTORY_LIMIT = 1000; +const DEFAULT_WS_HEARTBEAT_MS = 15_000; +const DEFAULT_NETWORK_HISTORY_LIMIT = 10_000; +const DEFAULT_NETWORK_BODY_CAPTURE_BYTES = 1_048_576; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const host = readString(env.CLOAKFETCH_HOST, DEFAULT_HOST); + validateListenHost(host); + + const proxy = parseProxy(env.CLOAKFETCH_PROXY); + assertProxyAuthSupported(proxy); + return { + host, + port: readInteger(env.CLOAKFETCH_PORT, DEFAULT_PORT, 1, 65_535, "CLOAKFETCH_PORT"), + profileDir: resolveProfilePath(readString(env.CLOAKFETCH_PROFILE, DEFAULT_PROFILE)), + proxy, + headless: readBoolean(env.CLOAKFETCH_HEADLESS, DEFAULT_HEADLESS, "CLOAKFETCH_HEADLESS"), + concurrency: readInteger( + env.CLOAKFETCH_CONCURRENCY, + DEFAULT_CONCURRENCY, + 1, + 64, + "CLOAKFETCH_CONCURRENCY", + ), + timeoutMs: readInteger( + env.CLOAKFETCH_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + 1_000, + 300_000, + "CLOAKFETCH_TIMEOUT_MS", + ), + maxBodyBytes: + readInteger( + env.CLOAKFETCH_MAX_BODY_MB, + DEFAULT_MAX_BODY_MB, + 1, + 1024, + "CLOAKFETCH_MAX_BODY_MB", + ) * + 1024 * + 1024, + historyLimit: readInteger( + env.CLOAKFETCH_HISTORY_LIMIT, + DEFAULT_HISTORY_LIMIT, + 100, + 100_000, + "CLOAKFETCH_HISTORY_LIMIT", + ), + websocketHeartbeatMs: readInteger( + env.CLOAKFETCH_WS_HEARTBEAT_MS, + DEFAULT_WS_HEARTBEAT_MS, + 5_000, + 60_000, + "CLOAKFETCH_WS_HEARTBEAT_MS", + ), + networkHistoryLimit: readInteger( + env.CLOAKFETCH_NETWORK_HISTORY_LIMIT, + DEFAULT_NETWORK_HISTORY_LIMIT, + 100, + 1_000_000, + "CLOAKFETCH_NETWORK_HISTORY_LIMIT", + ), + networkBodyCaptureBytes: readInteger( + env.CLOAKFETCH_NETWORK_BODY_CAPTURE_BYTES, + DEFAULT_NETWORK_BODY_CAPTURE_BYTES, + -1, + 128 * 1024 * 1024, + "CLOAKFETCH_NETWORK_BODY_CAPTURE_BYTES", + ), + }; +} + +export function normalizeProxyConfig(raw: unknown): ProxyConfig | undefined { + if (raw === undefined || raw === null || raw === "") { + return undefined; + } + if (typeof raw === "string") { + const proxy = parseProxy(raw); + assertProxyAuthSupported(proxy); + return proxy; + } + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new HttpError(400, "invalid_proxy", "proxy must be a URL string or proxy object"); + } + const value = raw as Partial; + if (typeof value.server !== "string") { + throw new HttpError(400, "invalid_proxy", "proxy.server is required"); + } + const proxy = parseProxy(buildProxyUrl(value)); + assertProxyAuthSupported(proxy); + return proxy; +} + +function readString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim(); + return trimmed ? trimmed : fallback; +} + +function validateListenHost(host: string): void { + if (net.isIP(host) !== 0) { + return; + } + throw new HttpError(400, "invalid_config", "CLOAKFETCH_HOST must be an IPv4 or IPv6 address"); +} + +function readInteger( + raw: string | undefined, + fallback: number, + min: number, + max: number, + name: string, +): number { + const value = raw?.trim() ? Number(raw) : fallback; + if (!Number.isInteger(value) || value < min || value > max) { + throw new HttpError(400, "invalid_config", `${name} must be an integer between ${min} and ${max}`); + } + return value; +} + +function readBoolean(raw: string | undefined, fallback: boolean, name: string): boolean { + const value = raw?.trim().toLowerCase(); + if (!value) { + return fallback; + } + if (["1", "true", "yes", "on"].includes(value)) { + return true; + } + if (["0", "false", "no", "off"].includes(value)) { + return false; + } + throw new HttpError(400, "invalid_config", `${name} must be true or false`); +} + +function parseProxy(raw: string | undefined): ProxyConfig | undefined { + const value = raw?.trim(); + if (!value) { + return undefined; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new HttpError(400, "invalid_config", "CLOAKFETCH_PROXY must be a valid URL"); + } + + if (!["http:", "https:", "socks4:", "socks5:"].includes(parsed.protocol)) { + throw new HttpError( + 400, + "invalid_config", + "CLOAKFETCH_PROXY protocol must be http, https, socks4, or socks5", + ); + } + const proxy: ProxyConfig = { + server: `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`, + }; + if (parsed.username) { + proxy.username = decodeURIComponent(parsed.username); + } + if (parsed.password) { + proxy.password = decodeURIComponent(parsed.password); + } + return proxy; +} + +function assertProxyAuthSupported(proxy: ProxyConfig | undefined): void { + if (!proxy?.username && !proxy?.password) { + return; + } + throw new HttpError( + 400, + "proxy_auth_not_supported", + "Authenticated CLOAKFETCH_PROXY is not supported safely by the current CloakBrowser package; use an unauthenticated local proxy endpoint", + ); +} + +function buildProxyUrl(proxy: Partial): string { + const url = new URL(proxy.server ?? ""); + if (proxy.username) { + url.username = proxy.username; + } + if (proxy.password) { + url.password = proxy.password; + } + return url.toString(); +} + +export function maskProxyUrl(proxy: ProxyConfig | undefined): string | null { + if (!proxy) { + return null; + } + const url = new URL(proxy.server); + const auth = proxy.username ? `${proxy.username}${proxy.password ? ":***" : ""}@` : ""; + return `${url.protocol}//${auth}${url.host}`; +} + +export function redactProxySecrets(message: string, proxy: ProxyConfig | undefined): string { + if (!proxy?.password) { + return message; + } + return message.split(proxy.password).join("***"); +} diff --git a/src/fetcher.ts b/src/fetcher.ts new file mode 100644 index 0000000..61afafb --- /dev/null +++ b/src/fetcher.ts @@ -0,0 +1,178 @@ +import type { CDPSession, Page, Request, Route } from "playwright-core"; +import type { Semaphore } from "./semaphore.js"; +import type { SourceSessionManager } from "./source_session_manager.js"; +import type { AppConfig, CacheMode, FetchRequestBody, FetchResult, FetchStats } from "./types.js"; +import { HttpError } from "./types.js"; +import { + assertBodySize, + pickSafeResponseHeaders, + sanitizeRequestHeaders, + validateFetchUrl, +} from "./security.js"; + +export class Fetcher { + private totalCount = 0; + private failedCount = 0; + + constructor( + private readonly config: AppConfig, + private readonly sourceManager: SourceSessionManager, + private readonly semaphore: Semaphore, + ) {} + + get active(): number { + return this.semaphore.active; + } + + get queued(): number { + return this.semaphore.queued; + } + + get stats(): FetchStats { + return { + lifetime_total: this.totalCount, + lifetime_failed: this.failedCount, + active: this.semaphore.active, + queued: this.semaphore.queued, + }; + } + + async fetchDocument(body: FetchRequestBody): Promise { + const release = await this.semaphore.acquire(); + const started = Date.now(); + this.totalCount += 1; + const source = this.sourceManager.require(body.source_id); + source.beginGatewayRequest(); + try { + const timeoutMs = normalizeTimeout(body.timeout_ms, this.config.timeoutMs); + const cacheMode = normalizeCacheMode(body.cache_mode); + const url = validateFetchUrl(body.url); + const headers = sanitizeRequestHeaders(body.headers); + const context = await source.ensureReady(); + const page = await context.newPage(); + let cdpSession: CDPSession | undefined; + let disposeInitialHeaders: (() => Promise) | undefined; + try { + page.setDefaultTimeout(timeoutMs); + page.setDefaultNavigationTimeout(timeoutMs); + if (cacheMode === "reload") { + cdpSession = await context.newCDPSession(page); + await cdpSession.send("Network.enable"); + await cdpSession.send("Network.setCacheDisabled", { cacheDisabled: true }); + } + disposeInitialHeaders = await installInitialNavigationHeaders(page, headers); + const response = await page.goto(url.toString(), { + waitUntil: "commit", + timeout: timeoutMs, + }); + await disposeInitialHeaders(); + disposeInitialHeaders = undefined; + if (!response) { + throw new HttpError(502, "no_response", "Browser navigation produced no response"); + } + const responseHeaders = response.headers(); + assertBodySize(responseHeaders, this.config.maxBodyBytes); + const finishError = await response.finished(); + if (finishError) { + throw new HttpError(502, "response_failed", finishError.message); + } + if (isHtmlResponse(responseHeaders)) { + await page.waitForLoadState("load", { timeout: Math.min(timeoutMs, 5_000) }).catch(() => undefined); + await page.waitForTimeout(500).catch(() => undefined); + } + const finalUrl = validateFetchUrl(response.url()); + const bodyBuffer = await response.body(); + if (bodyBuffer.byteLength > this.config.maxBodyBytes) { + throw new HttpError(413, "body_too_large", "Target response body is larger than CLOAKFETCH_MAX_BODY_MB"); + } + const status = response.status(); + if (status < 200 || status >= 400) { + this.failedCount += 1; + } + return { + status, + headers: pickSafeResponseHeaders(responseHeaders), + body: bodyBuffer, + finalUrl: finalUrl.toString(), + mode: "document", + cacheMode, + sourceId: source.id, + elapsedMs: Date.now() - started, + }; + } finally { + await disposeInitialHeaders?.().catch(() => undefined); + if (cdpSession) { + await cdpSession.send("Network.setCacheDisabled", { cacheDisabled: false }).catch(() => undefined); + await cdpSession.detach().catch(() => undefined); + } + await page.close({ runBeforeUnload: false }).catch(() => undefined); + } + } catch (error) { + this.failedCount += 1; + throw error; + } finally { + source.endGatewayRequest(); + release(); + } + } +} + +async function installInitialNavigationHeaders(page: Page, headers: Record): Promise<() => Promise> { + if (Object.keys(headers).length === 0) { + return async () => undefined; + } + let active = true; + const handler = async (route: Route, request: Request) => { + if (!active) { + await route.continue(); + return; + } + if (request.frame() === page.mainFrame() && request.isNavigationRequest()) { + active = false; + await page.unroute("**/*", handler).catch(() => undefined); + await route.continue({ headers: mergeInitialHeaders(request.headers(), headers) }); + return; + } + await route.continue(); + }; + await page.route("**/*", handler); + return async () => { + if (!active) { + return; + } + active = false; + await page.unroute("**/*", handler); + }; +} + +function mergeInitialHeaders(browserHeaders: Record, initialHeaders: Record): Record { + const headers: Record = { ...browserHeaders }; + for (const [name, value] of Object.entries(initialHeaders)) { + headers[name.toLowerCase()] = value; + } + return headers; +} + +function normalizeTimeout(rawTimeout: unknown, maxTimeoutMs: number): number { + if (rawTimeout === undefined || rawTimeout === null) { + return maxTimeoutMs; + } + if (typeof rawTimeout !== "number" || !Number.isInteger(rawTimeout) || rawTimeout < 1_000 || rawTimeout > maxTimeoutMs) { + throw new HttpError(400, "invalid_timeout", `timeout_ms must be an integer between 1000 and ${maxTimeoutMs}`); + } + return rawTimeout; +} + +function normalizeCacheMode(rawCacheMode: unknown): CacheMode { + if (rawCacheMode === undefined || rawCacheMode === null) { + return "default"; + } + if (rawCacheMode === "default" || rawCacheMode === "reload") { + return rawCacheMode; + } + throw new HttpError(400, "invalid_cache_mode", "cache_mode must be default or reload"); +} + +function isHtmlResponse(headers: Record): boolean { + return (headers["content-type"] ?? "").toLowerCase().includes("text/html"); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4343bae --- /dev/null +++ b/src/main.ts @@ -0,0 +1,73 @@ +import { loadConfig, maskProxyUrl, redactProxySecrets } from "./config.js"; +import { Fetcher } from "./fetcher.js"; +import { NetworkHistory } from "./network_history.js"; +import { RequestHistory } from "./request_history.js"; +import { Semaphore } from "./semaphore.js"; +import { buildServer } from "./server.js"; +import { SourceSessionManager } from "./source_session_manager.js"; +import type { AppConfig } from "./types.js"; +import { HttpError } from "./types.js"; +import { WebSocketHub } from "./websocket_hub.js"; + +let loadedConfig: AppConfig | undefined; + +async function main(): Promise { + const config = loadConfig(); + loadedConfig = config; + const semaphore = new Semaphore(config.concurrency); + const history = new RequestHistory(config.historyLimit); + const networkHistory = new NetworkHistory(config.networkHistoryLimit); + let fetcher: Fetcher; + const hub = new WebSocketHub(config.websocketHeartbeatMs, () => ({ + server_time: new Date().toISOString(), + stats: fetcher.stats, + dropped_event_count: hub.droppedEventCount, + })); + const sources = new SourceSessionManager(config, networkHistory, hub); + fetcher = new Fetcher(config, sources, semaphore); + const server = await buildServer(config, fetcher, history, networkHistory, sources, hub); + const shutdown = async (signal: NodeJS.Signals) => { + console.log(JSON.stringify({ event: "shutdown", signal })); + await server.close(); + await sources.closeAll(); + }; + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + process.once("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + await server.listen({ host: config.host, port: config.port }); + console.log( + JSON.stringify({ + event: "listening", + host: config.host, + port: config.port, + profile: config.profileDir, + proxy: maskProxyUrl(config.proxy), + headless: config.headless, + concurrency: config.concurrency, + timeout_ms: config.timeoutMs, + max_body_mb: Math.floor(config.maxBodyBytes / 1024 / 1024), + history_limit: config.historyLimit, + network_history_limit: config.networkHistoryLimit, + network_body_capture_bytes: config.networkBodyCaptureBytes, + websocket_heartbeat_ms: config.websocketHeartbeatMs, + }), + ); +} + +main().catch((error: unknown) => { + if (error instanceof HttpError) { + console.error(JSON.stringify({ event: "startup_failed", code: error.code, error: redactProxySecrets(error.message, loadedConfig?.proxy) })); + process.exitCode = 1; + return; + } + if (error instanceof Error) { + console.error(JSON.stringify({ event: "startup_failed", error: redactProxySecrets(error.message, loadedConfig?.proxy) })); + process.exitCode = 1; + return; + } + console.error(JSON.stringify({ event: "startup_failed", error: "Unknown error" })); + process.exitCode = 1; +}); diff --git a/src/network_history.ts b/src/network_history.ts new file mode 100644 index 0000000..3333b39 --- /dev/null +++ b/src/network_history.ts @@ -0,0 +1,172 @@ +import type { CapturedBody, NetworkRecentQuery, NetworkRecord, NetworkSummary } from "./types.js"; + +export class NetworkHistory { + private readonly records: NetworkRecord[] = []; + private readonly byId = new Map(); + private cursor = 0; + + constructor(readonly limit: number) {} + + upsert(record: NetworkRecord): void { + if (this.byId.has(record.id)) { + return; + } + this.add(record); + } + + update(id: string, patch: Partial): NetworkRecord | undefined { + const record = this.byId.get(id); + if (!record) { + return undefined; + } + Object.assign(record, patch); + return record; + } + + get(id: string): NetworkRecord | undefined { + return this.byId.get(id); + } + + body(id: string): { request_post_data: CapturedBody | null; response_body: CapturedBody | null } | undefined { + const record = this.byId.get(id); + if (!record) { + return undefined; + } + return { + request_post_data: record.request_post_data, + response_body: record.response_body, + }; + } + + recent(limit: number, query: NetworkRecentQuery = {}): NetworkRecord[] { + return this.all().filter((record) => matchesQuery(record, query)).slice(-limit).reverse(); + } + + summary(): NetworkSummary { + const records = this.all(); + const finished = records.filter((record) => record.finished_at !== null); + const failed = records.filter((record) => record.failure_text !== null); + const durations = finished + .map((record) => record.duration_ms) + .filter((duration): duration is number => duration !== null) + .sort((a, b) => a - b); + return { + retained_total: records.length, + active: records.length - finished.length, + finished: finished.length, + failed: failed.length, + by_method: countBy(records, (record) => record.method), + by_resource_type: countBy(records, (record) => record.resource_type), + by_status: countBy(records, (record) => record.status === null ? null : String(record.status)), + by_host: countBy(records, (record) => safeHost(record.url)), + by_protocol: countBy(records, (record) => record.protocol), + cache_hit_count: records.filter((record) => record.from_cache).length, + total_request_bytes: records.reduce((sum, record) => sum + record.request_body_size, 0), + total_response_bytes: records.reduce((sum, record) => sum + record.response_body_size, 0), + avg_duration_ms: durations.length === 0 ? 0 : Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length), + p95_duration_ms: percentile(durations, 0.95), + }; + } + + clear(): void { + this.records.length = 0; + this.byId.clear(); + this.cursor = 0; + } + + private add(record: NetworkRecord): void { + if (this.records.length < this.limit) { + this.records.push(record); + this.byId.set(record.id, record); + return; + } + const previous = this.records[this.cursor]; + if (previous) { + this.byId.delete(previous.id); + } + this.records[this.cursor] = record; + this.byId.set(record.id, record); + this.cursor = (this.cursor + 1) % this.limit; + } + + private all(): NetworkRecord[] { + if (this.records.length < this.limit) { + return [...this.records]; + } + return [...this.records.slice(this.cursor), ...this.records.slice(0, this.cursor)]; + } +} + +function matchesQuery(record: NetworkRecord, query: NetworkRecentQuery): boolean { + if (query.source_id && record.source_id !== query.source_id) { + return false; + } + if (query.page_id && record.page_id !== query.page_id) { + return false; + } + if (query.method && record.method.toLowerCase() !== query.method.toLowerCase()) { + return false; + } + if (query.resource_type && record.resource_type !== query.resource_type) { + return false; + } + if (query.status && String(record.status ?? "") !== query.status) { + return false; + } + if (query.host && safeHost(record.url)?.toLowerCase() !== query.host.toLowerCase()) { + return false; + } + if (query.failed !== undefined && parseBooleanQuery(query.failed) !== Boolean(record.failure_text)) { + return false; + } + if (query.from_cache !== undefined && parseBooleanQuery(query.from_cache) !== record.from_cache) { + return false; + } + if (query.text && !recordMatchesText(record, query.text)) { + return false; + } + return true; +} + +function parseBooleanQuery(value: string): boolean { + return ["1", "true", "yes"].includes(value.toLowerCase()); +} + +function recordMatchesText(record: NetworkRecord, text: string): boolean { + const query = text.toLowerCase(); + return [ + record.url, + record.method, + record.resource_type, + record.failure_text ?? "", + safeHost(record.url) ?? "", + ].some((value) => value.toLowerCase().includes(query)); +} + +function countBy(records: NetworkRecord[], valueOf: (record: NetworkRecord) => string | null): Record { + const counts: Record = {}; + for (const record of records) { + const value = valueOf(record); + if (!value) { + continue; + } + counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const index = Math.min(values.length - 1, Math.max(0, Math.ceil(values.length * p) - 1)); + return values[index] ?? 0; +} + +function safeHost(rawUrl: string): string | null { + try { + return new URL(rawUrl).hostname; + } catch { + return null; + } +} diff --git a/src/network_observer.ts b/src/network_observer.ts new file mode 100644 index 0000000..b9b3ba7 --- /dev/null +++ b/src/network_observer.ts @@ -0,0 +1,346 @@ +import type { BrowserContext, Page, Request, Response } from "playwright-core"; +import type { CapturedBody, NetworkRecord } from "./types.js"; +import type { NetworkHistory } from "./network_history.js"; +import type { WebSocketHub } from "./websocket_hub.js"; + +let networkSequence = 0; + +export interface NetworkObserverHooks { + pageIdForPage: (page: Page) => string | null; + ensurePage: (page: Page) => string; + touchPage: (page: Page) => void; + noteNetworkStarted: () => void; + noteNetworkFailed: () => void; +} + +export class NetworkObserver { + private readonly requestIds = new WeakMap(); + private readonly requestsByPage = new Map>(); + + constructor( + private readonly sourceId: string, + private readonly captureBytes: number, + private readonly history: NetworkHistory, + private readonly hub: WebSocketHub, + private readonly hooks: NetworkObserverHooks, + ) {} + + attachContext(context: BrowserContext): void { + context.on("request", (request) => { + void this.handleRequest(request); + }); + context.on("response", (response) => { + void this.handleResponse(response); + }); + context.on("requestfinished", (request) => { + void this.handleRequestFinished(request); + }); + context.on("requestfailed", (request) => { + void this.handleRequestFailed(request); + }); + context.on("page", (page) => this.attachPage(page)); + for (const page of context.pages()) { + this.attachPage(page); + } + } + + attachPage(page: Page): void { + this.hooks.ensurePage(page); + page.on("request", (request) => { + void this.handleRequest(request); + }); + page.on("response", (response) => { + void this.handleResponse(response); + }); + page.on("requestfinished", (request) => { + void this.handleRequestFinished(request); + }); + page.on("requestfailed", (request) => { + void this.handleRequestFailed(request); + }); + page.on("console", (message) => { + this.hub.broadcast("browser.console", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + type: message.type(), + text: message.text(), + location: message.location(), + }, true); + }); + page.on("pageerror", (error) => { + this.hub.broadcast("browser.pageerror", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + message: error.message, + stack: error.stack ?? null, + }, true); + }); + page.on("crash", () => { + this.hub.broadcast("browser.crash", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + }, true); + }); + page.on("dialog", (dialog) => { + this.hub.broadcast("browser.dialog", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + type: dialog.type(), + message: dialog.message(), + }, true); + }); + page.on("download", (download) => { + this.hub.broadcast("browser.download", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + url: download.url(), + suggested_filename: download.suggestedFilename(), + }, true); + }); + page.on("close", () => this.failOpenPageRequests(page)); + page.on("websocket", (ws) => { + this.hub.broadcast("network.request", { + source_id: this.sourceId, + page_id: this.hooks.pageIdForPage(page), + url: ws.url(), + resource_type: "websocket", + }, true); + }); + } + + private async handleRequest(request: Request): Promise { + if (this.requestIds.has(request)) { + return; + } + const id = nextNetworkId(); + this.requestIds.set(request, id); + this.hooks.noteNetworkStarted(); + const page = safePage(request); + if (page) { + this.hooks.ensurePage(page); + this.hooks.touchPage(page); + } + const redirectedFrom = request.redirectedFrom(); + const parentId = redirectedFrom ? this.requestIds.get(redirectedFrom) ?? null : null; + const requestContentType = await request.headerValue("content-type").catch(() => null); + const requestBody = captureBuffer(request.postDataBuffer(), this.captureBytes, requestContentType); + const record: NetworkRecord = { + id, + source_id: this.sourceId, + page_id: page ? this.hooks.pageIdForPage(page) : null, + request_id: id, + parent_request_id: parentId, + started_at: new Date().toISOString(), + finished_at: null, + duration_ms: null, + url: request.url(), + method: request.method(), + resource_type: request.resourceType(), + navigation: request.isNavigationRequest(), + frame_url: safeFrameUrl(request), + initiator: null, + request_headers: request.headers(), + request_body_size: requestBody?.size ?? 0, + request_post_data: requestBody, + status: null, + status_text: null, + protocol: null, + response_headers: null, + response_body_size: 0, + response_body: null, + mime_type: null, + remote_address: null, + from_cache: false, + service_worker: Boolean(request.serviceWorker()), + failure_text: null, + redirect_from: parentId ? redirectedFrom?.url() ?? null : null, + redirect_to: null, + }; + this.history.upsert(record); + this.trackPageRequest(record.page_id, id); + void request.allHeaders().then((request_headers) => { + this.history.update(id, { request_headers }); + }).catch(() => undefined); + if (parentId) { + this.history.update(parentId, { redirect_to: record.url }); + } + this.hub.broadcast("network.request", record, true); + } + + private async handleResponse(response: Response): Promise { + const request = response.request(); + const id = this.requestIds.get(request); + if (!id) { + return; + } + const headers = await response.allHeaders().catch(() => response.headers()); + const remote = await response.serverAddr().catch(() => null); + const record = this.history.update(id, { + status: response.status(), + status_text: response.statusText(), + protocol: await response.httpVersion().catch(() => null), + response_headers: headers, + mime_type: headers["content-type"] ?? response.headers()["content-type"] ?? null, + remote_address: remote ? { ip_address: remote.ipAddress, port: remote.port } : null, + service_worker: response.fromServiceWorker(), + from_cache: isCacheHit(response, headers), + }); + if (record) { + this.hub.broadcast("network.response", record, true); + } + } + + private async handleRequestFinished(request: Request): Promise { + const id = this.requestIds.get(request); + if (!id) { + return; + } + const response = await request.response(); + const record = this.history.get(id); + const body = response ? await response.body().then((buffer) => captureBuffer(buffer, this.captureBytes, response.headers()["content-type"])).catch(() => null) : null; + const sizes = await request.sizes().catch(() => null); + const finishedAt = new Date().toISOString(); + const updated = this.history.update(id, { + finished_at: finishedAt, + duration_ms: record ? Date.parse(finishedAt) - Date.parse(record.started_at) : null, + request_body_size: sizes?.requestBodySize ?? record?.request_body_size ?? 0, + response_body_size: body?.size ?? sizes?.responseBodySize ?? 0, + response_body: body, + }); + if (updated) { + this.untrackPageRequest(updated.page_id, id); + this.hub.broadcast("network.finished", updated, true); + } + } + + private async handleRequestFailed(request: Request): Promise { + const id = this.requestIds.get(request); + if (!id) { + return; + } + this.hooks.noteNetworkFailed(); + const failure = request.failure(); + const record = this.history.get(id); + const finishedAt = new Date().toISOString(); + const updated = this.history.update(id, { + finished_at: finishedAt, + duration_ms: record ? Date.parse(finishedAt) - Date.parse(record.started_at) : null, + failure_text: failure?.errorText ?? "request failed", + }); + if (updated) { + this.untrackPageRequest(updated.page_id, id); + this.hub.broadcast("network.failed", updated, true); + } + } + + private failOpenPageRequests(page: Page): void { + const pageId = this.hooks.pageIdForPage(page); + if (!pageId) { + return; + } + const ids = this.requestsByPage.get(pageId); + if (!ids) { + return; + } + for (const id of ids) { + const record = this.history.get(id); + if (!record || record.finished_at) { + continue; + } + const finishedAt = new Date().toISOString(); + const updated = this.history.update(id, { + finished_at: finishedAt, + duration_ms: Date.parse(finishedAt) - Date.parse(record.started_at), + failure_text: "page closed before request completed", + }); + if (updated) { + this.hooks.noteNetworkFailed(); + this.hub.broadcast("network.failed", updated, true); + } + } + this.requestsByPage.delete(pageId); + } + + private trackPageRequest(pageId: string | null, requestId: string): void { + if (!pageId) { + return; + } + const requests = this.requestsByPage.get(pageId) ?? new Set(); + requests.add(requestId); + this.requestsByPage.set(pageId, requests); + } + + private untrackPageRequest(pageId: string | null, requestId: string): void { + if (!pageId) { + return; + } + const requests = this.requestsByPage.get(pageId); + if (!requests) { + return; + } + requests.delete(requestId); + if (requests.size === 0) { + this.requestsByPage.delete(pageId); + } + } +} + +function nextNetworkId(): string { + networkSequence = (networkSequence + 1) % 1_000_000_000; + return `net-${networkSequence.toString().padStart(9, "0")}`; +} + +function captureBuffer(buffer: Buffer | null, limit: number, contentType?: string | null): CapturedBody | null { + if (!buffer) { + return null; + } + const size = buffer.byteLength; + const stored = limit === -1 ? buffer : buffer.subarray(0, Math.max(0, Math.min(limit, size))); + const isText = isTextContent(contentType); + if (limit === 0) { + return { size, stored_bytes: 0, truncated: size > 0, encoding: null }; + } + if (isText) { + return { + size, + stored_bytes: stored.byteLength, + truncated: stored.byteLength < size, + encoding: "utf8", + text: stored.toString("utf8"), + }; + } + return { + size, + stored_bytes: stored.byteLength, + truncated: stored.byteLength < size, + encoding: "base64", + base64: stored.toString("base64"), + }; +} + +function isTextContent(contentType?: string | null): boolean { + const value = contentType?.toLowerCase() ?? ""; + return value.startsWith("text/") || value.includes("json") || value.includes("javascript") || value.includes("xml") || value.includes("html") || value.includes("css"); +} + +function safePage(request: Request): Page | null { + try { + return request.frame().page(); + } catch { + return null; + } +} + +function safeFrameUrl(request: Request): string | null { + try { + return request.frame().url(); + } catch { + return null; + } +} + +function isCacheHit(response: Response, headers: Record): boolean { + const xCache = headers["x-cache"]?.toLowerCase() ?? ""; + const cfCache = headers["cf-cache-status"]?.toLowerCase() ?? ""; + return response.status() === 304 || response.fromServiceWorker() || xCache.includes("hit") || cfCache === "hit" || headers.age !== undefined; +} diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..f95bb44 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +export const projectRoot = inferProjectRoot(moduleDir); + +export function resolveProjectPath(...segments: string[]): string { + return path.resolve(projectRoot, ...segments); +} + +export function resolveProfilePath(profilePath: string): string { + return path.isAbsolute(profilePath) ? profilePath : resolveProjectPath(profilePath); +} + +export function sourceProfilePath(profileRootDir: string, sourceId: string): string { + return path.resolve(profileRootDir, "sources", sourceId); +} + +export function webDistPath(): string { + return resolveProjectPath("web", "dist"); +} + +function inferProjectRoot(dir: string): string { + const base = path.basename(dir); + if (base === "src" || base === "dist") { + return path.dirname(dir); + } + return dir; +} diff --git a/src/request_history.ts b/src/request_history.ts new file mode 100644 index 0000000..dc7fa95 --- /dev/null +++ b/src/request_history.ts @@ -0,0 +1,110 @@ +import type { FetchStats, MetricsSummary, MetricsTimeseriesPoint, RequestRecord } from "./types.js"; + +export class RequestHistory { + private readonly records: RequestRecord[] = []; + private cursor = 0; + + constructor(readonly limit: number) {} + + add(record: RequestRecord): void { + if (this.records.length < this.limit) { + this.records.push(record); + return; + } + this.records[this.cursor] = record; + this.cursor = (this.cursor + 1) % this.limit; + } + + recent(limit: number): RequestRecord[] { + return this.all().slice(-limit).reverse(); + } + + summary(stats: FetchStats, proxyConfigured: boolean, proxyUrlMasked: string | null): MetricsSummary { + const records = this.all(); + const elapsedValues = records.map((record) => record.elapsed_ms).sort((a, b) => a - b); + const totalElapsed = elapsedValues.reduce((sum, value) => sum + value, 0); + const success = records.filter((record) => record.ok).length; + return { + retained_total: records.length, + retained_success: success, + retained_failed: records.length - success, + active: stats.active, + queued: stats.queued, + avg_elapsed_ms: records.length === 0 ? 0 : Math.round(totalElapsed / records.length), + p95_elapsed_ms: percentile(elapsedValues, 0.95), + total_body_size: records.reduce((sum, record) => sum + record.body_size, 0), + status_counts: countBy(records, (record) => (record.status === null ? null : String(record.status))), + error_counts: countBy(records, (record) => record.error_code), + host_counts: countBy(records, (record) => record.host), + proxy_configured: proxyConfigured, + proxy_url_masked: proxyUrlMasked, + }; + } + + timeseries(windowSec: number, nowMs = Date.now()): MetricsTimeseriesPoint[] { + const endSec = Math.floor(nowMs / 1000); + const startSec = endSec - windowSec + 1; + const buckets = new Map(); + for (let second = startSec; second <= endSec; second += 1) { + buckets.set(second, { total: 0, success: 0, failed: 0, elapsedSum: 0, bodySize: 0 }); + } + for (const record of this.all()) { + const recordSec = Math.floor(Date.parse(record.time) / 1000); + const bucket = buckets.get(recordSec); + if (!bucket) { + continue; + } + bucket.total += 1; + bucket.elapsedSum += record.elapsed_ms; + bucket.bodySize += record.body_size; + if (record.ok) { + bucket.success += 1; + } else { + bucket.failed += 1; + } + } + return [...buckets.entries()].map(([second, bucket]) => ({ + time: new Date(second * 1000).toISOString(), + total: bucket.total, + success: bucket.success, + failed: bucket.failed, + avg_elapsed_ms: bucket.total === 0 ? 0 : Math.round(bucket.elapsedSum / bucket.total), + body_size: bucket.bodySize, + })); + } + + private all(): RequestRecord[] { + if (this.records.length < this.limit) { + return [...this.records]; + } + return [...this.records.slice(this.cursor), ...this.records.slice(0, this.cursor)]; + } +} + +interface MutableTimeseriesBucket { + total: number; + success: number; + failed: number; + elapsedSum: number; + bodySize: number; +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const index = Math.min(values.length - 1, Math.max(0, Math.ceil(values.length * p) - 1)); + return values[index] ?? 0; +} + +function countBy(records: RequestRecord[], valueOf: (record: RequestRecord) => string | null): Record { + const counts: Record = {}; + for (const record of records) { + const value = valueOf(record); + if (!value) { + continue; + } + counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..49d37e6 --- /dev/null +++ b/src/security.ts @@ -0,0 +1,99 @@ +import { domainToASCII } from "node:url"; +import { HttpError } from "./types.js"; + +const SAFE_RESPONSE_HEADERS = [ + "content-type", + "cache-control", + "etag", + "expires", + "last-modified", +] as const; + +const FORBIDDEN_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "upgrade", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "accept-encoding", +]); + +const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9a-zA-Z]+$/; + +export function validateFetchUrl(rawUrl: string): URL { + if (typeof rawUrl !== "string" || rawUrl.trim() === "") { + throw new HttpError(400, "invalid_url", "url is required"); + } + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new HttpError(400, "invalid_url", "url must be a valid absolute URL"); + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new HttpError(400, "invalid_url_scheme", "Only http and https URLs are supported"); + } + const hostname = normalizeHostname(url.hostname); + if (!hostname) { + throw new HttpError(400, "invalid_url_host", "URL host is required"); + } + return url; +} + +export function sanitizeRequestHeaders(raw: unknown): Record { + if (raw === undefined || raw === null) { + return {}; + } + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new HttpError(400, "invalid_headers", "headers must be an object"); + } + const result: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const headerName = name.trim(); + const lowerName = headerName.toLowerCase(); + if (!HEADER_NAME_RE.test(headerName)) { + throw new HttpError(400, "invalid_header_name", `Invalid header name: ${name}`); + } + if (FORBIDDEN_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith("sec-fetch-")) { + throw new HttpError(400, "forbidden_header", `Header is not allowed: ${name}`); + } + if (typeof value !== "string") { + throw new HttpError(400, "invalid_header_value", `Header value must be a string: ${name}`); + } + result[headerName] = value; + } + return result; +} + +export function pickSafeResponseHeaders(headers: Record): Record { + const result: Record = {}; + for (const name of SAFE_RESPONSE_HEADERS) { + const value = headers[name]; + if (value !== undefined) { + result[name] = value; + } + } + return result; +} + +export function assertBodySize(headers: Record, maxBodyBytes: number): void { + const rawLength = headers["content-length"]; + if (!rawLength) { + return; + } + const contentLength = Number(rawLength); + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + throw new HttpError(413, "body_too_large", "Target response body is larger than CLOAKFETCH_MAX_BODY_MB"); + } +} + +function normalizeHostname(hostname: string): string { + const withoutBrackets = hostname.replace(/^\[/, "").replace(/\]$/, ""); + return domainToASCII(withoutBrackets).toLowerCase().replace(/\.$/, ""); +} diff --git a/src/semaphore.ts b/src/semaphore.ts new file mode 100644 index 0000000..940ceb5 --- /dev/null +++ b/src/semaphore.ts @@ -0,0 +1,42 @@ +export class Semaphore { + private activeCount = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly limit: number) { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error("Semaphore limit must be a positive integer"); + } + } + + get active(): number { + return this.activeCount; + } + + get queued(): number { + return this.waiters.length; + } + + async acquire(): Promise<() => void> { + if (this.activeCount < this.limit) { + this.activeCount += 1; + return () => this.release(); + } + + await new Promise((resolve) => { + this.waiters.push(resolve); + }); + this.activeCount += 1; + return () => this.release(); + } + + private release(): void { + if (this.activeCount === 0) { + return; + } + this.activeCount -= 1; + const next = this.waiters.shift(); + if (next) { + next(); + } + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..68e012a --- /dev/null +++ b/src/server.ts @@ -0,0 +1,586 @@ +import fastifyStatic from "@fastify/static"; +import websocket from "@fastify/websocket"; +import Fastify from "fastify"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { maskProxyUrl, redactProxySecrets } from "./config.js"; +import type { Fetcher } from "./fetcher.js"; +import type { NetworkHistory } from "./network_history.js"; +import { projectRoot, webDistPath } from "./paths.js"; +import type { RequestHistory } from "./request_history.js"; +import type { SourceSessionManager } from "./source_session_manager.js"; +import { redactUrlForLogs } from "./url_redaction.js"; +import type { WebSocketHub } from "./websocket_hub.js"; +import type { + AppConfig, + CacheMode, + DiagnosticsFetchResult, + FetchRequestBody, + FetchResult, + NetworkRecentQuery, + RequestKind, + RequestLog, + RequestRecord, + SourceSessionConfig, + WarmupRequestBody, +} from "./types.js"; +import { HttpError } from "./types.js"; + +let requestSequence = 0; + +interface RecentQuery { + limit?: string; +} + +interface TimeseriesQuery { + window_sec?: string; +} + +interface IdParams { + id: string; +} + +export async function buildServer( + config: AppConfig, + fetcher: Fetcher, + history: RequestHistory, + networkHistory: NetworkHistory, + sources: SourceSessionManager, + hub: WebSocketHub, +): Promise { + const app = Fastify({ + logger: false, + bodyLimit: 1024 * 1024, + trustProxy: true, + }); + await app.register(websocket); + app.get("/health", async () => { + return { status: "ok" }; + }); + registerFetchRoutes(app, config, fetcher, history, sources, hub); + registerSourceRoutes(app, sources); + registerMetricsRoutes(app, config, fetcher, history, networkHistory, sources, hub); + registerNetworkRoutes(app, config, networkHistory); + app.get("/v1/ws", { websocket: true }, (socket) => { + hub.addClient(socket, { + server_time: new Date().toISOString(), + stats: fetcher.stats, + summary: buildSummary(config, fetcher, history), + network_summary: networkHistory.summary(), + sources: sources.list(), + dropped_event_count: hub.droppedEventCount, + }); + }); + app.addHook("onClose", async () => { + hub.stopHeartbeat(); + }); + await registerConsoleRoutes(app); + hub.startHeartbeat(); + return app; +} + +function registerFetchRoutes( + app: FastifyInstance, + config: AppConfig, + fetcher: Fetcher, + history: RequestHistory, + sources: SourceSessionManager, + hub: WebSocketHub, +): void { + app.post( + "/v1/fetch", + async (request: FastifyRequest<{ Body: FetchRequestBody }>, reply: FastifyReply) => { + const requestId = nextRequestId(); + const started = Date.now(); + const body = request.body; + const requestUrl = typeof body?.url === "string" ? body.url : ""; + const sourceId = typeof body?.source_id === "string" ? body.source_id : sources.defaultSourceId; + const logBase = buildLogBase(requestId, "document", requestUrl, sourceId); + try { + if (!isFetchRequestBody(body)) { + throw new HttpError(400, "invalid_request", "Request body must include a url string"); + } + const result = await fetcher.fetchDocument(body); + const elapsedMs = Date.now() - started; + const record = buildResultRecord(config, requestId, "fetch", body.url, result, elapsedMs); + recordRequest(history, hub, record); + const responseHeaders = { + ...result.headers, + "x-cloakfetch-final-url": result.finalUrl, + "x-cloakfetch-fetch-mode": result.mode, + }; + logRequest({ + ...logBase, + status: result.status, + body_size: result.body.byteLength, + elapsed_ms: elapsedMs, + final_url: redactUrlForLogs(result.finalUrl), + error: record.error_message ?? undefined, + }); + return reply.status(result.status).headers(responseHeaders).send(result.body); + } catch (error) { + const httpError = toHttpError(error, config); + const elapsedMs = Date.now() - started; + const record = buildErrorRecord(config, requestId, "fetch", requestUrl, sourceId, requestCacheMode(body, "default"), httpError, elapsedMs); + recordRequest(history, hub, record); + logRequest({ + ...logBase, + elapsed_ms: elapsedMs, + error: `${record.error_code}: ${record.error_message}`, + }); + return reply.status(httpError.statusCode).send({ + error: { + code: httpError.code, + message: httpError.message, + }, + }); + } + }, + ); + app.post( + "/v1/diagnostics/fetch", + async (request: FastifyRequest<{ Body: FetchRequestBody }>, reply: FastifyReply) => { + const requestId = nextRequestId(); + const started = Date.now(); + const body = request.body; + const requestUrl = typeof body?.url === "string" ? body.url : ""; + const sourceId = typeof body?.source_id === "string" ? body.source_id : sources.defaultSourceId; + const logBase = buildLogBase(requestId, "document", requestUrl, sourceId); + try { + if (!isFetchRequestBody(body)) { + throw new HttpError(400, "invalid_request", "Request body must include a url string"); + } + const diagnosticBody: FetchRequestBody = { ...body, cache_mode: "reload" }; + const result = await fetcher.fetchDocument(diagnosticBody); + const elapsedMs = Date.now() - started; + const record = buildResultRecord(config, requestId, "diagnostics", body.url, result, elapsedMs); + recordRequest(history, hub, record); + const diagnostic: DiagnosticsFetchResult = { + url: body.url, + final_url: result.finalUrl, + status: result.status, + ok: record.ok, + content_type: record.content_type, + body_size: record.body_size, + elapsed_ms: record.elapsed_ms, + fetch_mode: result.mode, + error: record.error_message, + }; + logRequest({ + ...logBase, + status: result.status, + body_size: result.body.byteLength, + elapsed_ms: elapsedMs, + final_url: redactUrlForLogs(result.finalUrl), + error: record.error_message ?? undefined, + }); + return reply.status(200).send(diagnostic); + } catch (error) { + const httpError = toHttpError(error, config); + const elapsedMs = Date.now() - started; + const record = buildErrorRecord(config, requestId, "diagnostics", requestUrl, sourceId, "reload", httpError, elapsedMs); + recordRequest(history, hub, record); + const diagnostic: DiagnosticsFetchResult = { + url: requestUrl, + final_url: null, + status: null, + ok: false, + content_type: null, + body_size: 0, + elapsed_ms: elapsedMs, + fetch_mode: "document", + error: `${record.error_code}: ${record.error_message}`, + }; + logRequest({ + ...logBase, + elapsed_ms: elapsedMs, + error: diagnostic.error ?? undefined, + }); + return reply.status(httpError.statusCode).send(diagnostic); + } + }, + ); +} + +function registerSourceRoutes(app: FastifyInstance, sources: SourceSessionManager): void { + app.post( + "/v1/sources", + async (request: FastifyRequest<{ Body: SourceSessionConfig }>, reply: FastifyReply) => { + try { + if (typeof request.body?.id !== "string") { + throw new HttpError(400, "invalid_request", "source id is required"); + } + return await sources.create(request.body); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); + app.get("/v1/sources", async () => { + return sources.list(); + }); + app.get( + "/v1/sources/:id", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + try { + return sources.require(request.params.id).runtime(); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); + app.delete( + "/v1/sources/:id", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + try { + return await sources.remove(request.params.id); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); + app.post( + "/v1/sources/:id/warmup", + async (request: FastifyRequest<{ Params: IdParams; Body: WarmupRequestBody }>, reply: FastifyReply) => { + try { + return await sources.warmup(request.params.id, request.body ?? {}); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); + app.get( + "/v1/sources/:id/cookies", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + try { + return await sources.require(request.params.id).cookies(); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); + app.get( + "/v1/sources/:id/pages", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + try { + return await sources.require(request.params.id).pageList(); + } catch (error) { + return sendHttpError(reply, toHttpError(error)); + } + }, + ); +} + +function registerMetricsRoutes( + app: FastifyInstance, + config: AppConfig, + fetcher: Fetcher, + history: RequestHistory, + networkHistory: NetworkHistory, + sources: SourceSessionManager, + hub: WebSocketHub, +): void { + app.get("/v1/stats", async () => { + return fetcher.stats; + }); + app.get("/v1/diagnostics/runtime", async () => { + return { + node_version: process.version, + platform: process.platform, + arch: process.arch, + cwd: process.cwd(), + project_root: projectRoot, + host: config.host, + port: config.port, + headless: config.headless, + profile_dir: config.profileDir, + proxy_configured: Boolean(config.proxy), + proxy_url_masked: maskProxyUrl(config.proxy), + concurrency: config.concurrency, + timeout_ms: config.timeoutMs, + max_body_mb: Math.floor(config.maxBodyBytes / 1024 / 1024), + history_limit: config.historyLimit, + network_history_limit: config.networkHistoryLimit, + network_body_capture_bytes: config.networkBodyCaptureBytes, + websocket_heartbeat_ms: config.websocketHeartbeatMs, + websocket_clients: hub.clientCount, + dropped_event_count: hub.droppedEventCount, + stats: fetcher.stats, + sources: sources.list(), + network_summary: networkHistory.summary(), + }; + }); + app.get( + "/v1/requests/recent", + async (request: FastifyRequest<{ Querystring: RecentQuery }>, reply: FastifyReply) => { + try { + const limit = readQueryInteger(request.query.limit, Math.min(200, config.historyLimit), 1, config.historyLimit, "limit"); + return history.recent(limit); + } catch (error) { + return sendHttpError(reply, toHttpError(error, config)); + } + }, + ); + app.get("/v1/metrics/summary", async () => { + return buildSummary(config, fetcher, history); + }); + app.get( + "/v1/metrics/timeseries", + async (request: FastifyRequest<{ Querystring: TimeseriesQuery }>, reply: FastifyReply) => { + try { + const windowSec = readQueryInteger(request.query.window_sec, 60, 10, 3600, "window_sec"); + return history.timeseries(windowSec); + } catch (error) { + return sendHttpError(reply, toHttpError(error, config)); + } + }, + ); +} + +function registerNetworkRoutes(app: FastifyInstance, config: AppConfig, networkHistory: NetworkHistory): void { + app.get( + "/v1/network/recent", + async (request: FastifyRequest<{ Querystring: NetworkRecentQuery }>, reply: FastifyReply) => { + try { + const limit = readQueryInteger(request.query.limit, Math.min(200, config.networkHistoryLimit), 1, config.networkHistoryLimit, "limit"); + return networkHistory.recent(limit, request.query); + } catch (error) { + return sendHttpError(reply, toHttpError(error, config)); + } + }, + ); + app.get("/v1/network/summary", async () => { + return networkHistory.summary(); + }); + app.delete("/v1/network/history", async () => { + networkHistory.clear(); + return { status: "ok" }; + }); + app.get( + "/v1/network/:id/body", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + const body = networkHistory.body(request.params.id); + if (!body) { + return sendHttpError(reply, new HttpError(404, "network_record_not_found", `Network record not found: ${request.params.id}`)); + } + return body; + }, + ); + app.get( + "/v1/network/:id", + async (request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) => { + const record = networkHistory.get(request.params.id); + if (!record) { + return sendHttpError(reply, new HttpError(404, "network_record_not_found", `Network record not found: ${request.params.id}`)); + } + return record; + }, + ); +} + +async function registerConsoleRoutes(app: FastifyInstance): Promise { + const webDist = webDistPath(); + const indexPath = path.join(webDist, "index.html"); + if (!(await pathExists(indexPath))) { + const missingConsole = async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.status(404).send({ + error: { + code: "console_not_built", + message: "Web console is not built. Run npm run web:build or npm run build.", + }, + }); + }; + app.get("/console", missingConsole); + app.get("/console/*", missingConsole); + return; + } + await app.register(fastifyStatic, { + root: webDist, + prefix: "/console/", + decorateReply: false, + }); + const sendIndex = async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.type("text/html; charset=utf-8").send(await fs.readFile(indexPath, "utf8")); + }; + app.get("/console", sendIndex); + app.get("/console/", sendIndex); +} + +function buildSummary(config: AppConfig, fetcher: Fetcher, history: RequestHistory) { + return history.summary(fetcher.stats, Boolean(config.proxy), maskProxyUrl(config.proxy)); +} + +function isFetchRequestBody(body: unknown): body is FetchRequestBody { + return typeof body === "object" && body !== null && typeof (body as FetchRequestBody).url === "string"; +} + +function buildLogBase(requestId: string, mode: "document", url: string, sourceId: string): RequestLog { + return { + request_id: requestId, + mode, + url: redactUrlForLogs(url), + host: safeHost(url) ?? undefined, + source_id: sourceId, + }; +} + +function buildResultRecord( + config: AppConfig, + id: string, + kind: RequestKind, + url: string, + result: FetchResult, + elapsedMs: number, +): RequestRecord { + const ok = isSuccessStatus(result.status); + return { + id, + time: new Date().toISOString(), + kind, + fetch_mode: result.mode, + cache_mode: result.cacheMode, + url: redactUrlForLogs(url), + host: safeHost(url), + final_url: redactUrlForLogs(result.finalUrl), + status: result.status, + ok, + error_code: ok ? null : `http_${result.status}`, + error_message: ok ? null : `Target returned HTTP ${result.status}`, + elapsed_ms: elapsedMs, + body_size: result.body.byteLength, + content_type: result.headers["content-type"] ?? null, + proxy_configured: Boolean(config.proxy), + proxy_url_masked: maskProxyUrl(config.proxy), + source_id: result.sourceId, + }; +} + +function buildErrorRecord( + config: AppConfig, + id: string, + kind: RequestKind, + url: string, + sourceId: string, + cacheMode: CacheMode, + error: HttpError, + elapsedMs: number, +): RequestRecord { + const errorCode = extractErrorCode(error); + return { + id, + time: new Date().toISOString(), + kind, + fetch_mode: "document", + cache_mode: cacheMode, + url: redactUrlForLogs(url), + host: safeHost(url), + final_url: null, + status: null, + ok: false, + error_code: errorCode, + error_message: error.message, + elapsed_ms: elapsedMs, + body_size: 0, + content_type: null, + proxy_configured: Boolean(config.proxy), + proxy_url_masked: maskProxyUrl(config.proxy), + source_id: sourceId, + }; +} + +function recordRequest(history: RequestHistory, hub: WebSocketHub, record: RequestRecord): void { + history.add(record); + hub.broadcast("request", record); +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 400; +} + +function toHttpError(error: unknown, config?: AppConfig): HttpError { + if (error instanceof HttpError) { + return sanitizeHttpError(error, config); + } + if (error instanceof Error && error.name === "TimeoutError") { + return sanitizeHttpError(new HttpError(504, "timeout", error.message), config); + } + if (error instanceof Error) { + return sanitizeHttpError(new HttpError(502, "fetch_failed", error.message), config); + } + return new HttpError(500, "internal_error", "Unknown error"); +} + +function sanitizeHttpError(error: HttpError, config: AppConfig | undefined): HttpError { + const message = redactProxySecrets(error.message, config?.proxy); + if (message === error.message) { + return error; + } + return new HttpError(error.statusCode, error.code, message); +} + +function extractErrorCode(error: HttpError): string { + const netError = error.message.match(/\b(ERR_[A-Z0-9_]+)\b/); + return netError?.[1] ?? error.code; +} + +function readQueryInteger( + raw: string | undefined, + fallback: number, + min: number, + max: number, + name: string, +): number { + if (raw === undefined) { + return fallback; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < min || value > max) { + throw new HttpError(400, "invalid_query", `${name} must be an integer between ${min} and ${max}`); + } + return value; +} + +function sendHttpError(reply: FastifyReply, error: HttpError) { + return reply.status(error.statusCode).send({ + error: { + code: error.code, + message: error.message, + }, + }); +} + +function nextRequestId(): string { + const date = new Date(); + const ymd = date.toISOString().slice(0, 10).replace(/-/g, ""); + requestSequence = (requestSequence + 1) % 1_000_000; + return `${ymd}-${requestSequence.toString().padStart(6, "0")}`; +} + +function safeHost(rawUrl: string): string | null { + try { + return new URL(rawUrl).hostname; + } catch { + return null; + } +} + +function logRequest(entry: RequestLog): void { + console.log(JSON.stringify(entry)); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function requestCacheMode(body: unknown, fallback: CacheMode): CacheMode { + if (typeof body === "object" && body !== null && (body as FetchRequestBody).cache_mode === "reload") { + return "reload"; + } + return fallback; +} diff --git a/src/source_session.ts b/src/source_session.ts new file mode 100644 index 0000000..d85a1fd --- /dev/null +++ b/src/source_session.ts @@ -0,0 +1,292 @@ +import fs from "node:fs/promises"; +import { launchPersistentContext } from "cloakbrowser"; +import type { BrowserContext, Cookie, Page } from "playwright-core"; +import { maskProxyUrl, normalizeProxyConfig } from "./config.js"; +import { NetworkObserver } from "./network_observer.js"; +import { resolveProfilePath } from "./paths.js"; +import { sanitizeRequestHeaders, validateFetchUrl } from "./security.js"; +import type { NetworkHistory } from "./network_history.js"; +import type { WebSocketHub } from "./websocket_hub.js"; +import type { + AppConfig, + SourcePageInfo, + SourceSessionConfig, + SourceSessionRuntime, + SourceSessionState, + WarmupRequestBody, +} from "./types.js"; +import { HttpError } from "./types.js"; + +export class SourceSession { + private context?: BrowserContext; + private startPromise?: Promise; + private observer?: NetworkObserver; + private state: SourceSessionState = "closed"; + private readonly pages = new Map(); + private pageSeq = 0; + private readyAt: string | null = null; + private lastUsedAt: string | null = null; + private requestCount = 0; + private activeRequestCount = 0; + private totalNetworkRequestCount = 0; + private totalNetworkFailedCount = 0; + private lastError: string | null = null; + readonly id: string; + readonly profileDir: string; + readonly createdAt = new Date().toISOString(); + + constructor( + private readonly appConfig: AppConfig, + private readonly sessionConfig: SourceSessionConfig, + private readonly networkHistory: NetworkHistory, + private readonly hub: WebSocketHub, + ) { + this.id = sessionConfig.id; + this.profileDir = resolveProfilePath(sessionConfig.profile_dir ?? `profile/sources/${sessionConfig.id}`); + } + + runtime(): SourceSessionRuntime { + const openPages = [...this.pages.values()].filter((page) => !page.closed); + return { + id: this.id, + state: this.state, + persistent: true, + profile_dir: this.profileDir, + warmup_url: this.sessionConfig.warmup_url ?? null, + current_url: openPages[0]?.url ?? null, + created_at: this.createdAt, + ready_at: this.readyAt, + last_used_at: this.lastUsedAt, + request_count: this.requestCount, + page_count: openPages.length, + active_request_count: this.activeRequestCount, + total_network_request_count: this.totalNetworkRequestCount, + total_network_failed_count: this.totalNetworkFailedCount, + proxy_configured: Boolean(this.proxy), + proxy_url_masked: maskProxyUrl(this.proxy), + last_error: this.lastError, + }; + } + + async ensureReady(): Promise { + if (this.context && this.state === "ready") { + return this.context; + } + if (!this.startPromise) { + this.startPromise = this.start(); + this.startPromise.catch((error) => { + if (this.startPromise) { + this.startPromise = undefined; + } + this.state = "failed"; + this.lastError = error instanceof Error ? error.message : String(error); + this.hub.broadcast("source.failed", this.runtime()); + }); + } + return this.startPromise; + } + + async newPage(): Promise { + const context = await this.ensureReady(); + const page = await context.newPage(); + this.ensurePage(page); + return page; + } + + async warmup(body: WarmupRequestBody = {}): Promise { + const url = body.url ?? this.sessionConfig.warmup_url; + if (!url) { + throw new HttpError(400, "missing_warmup_url", "warmup url is required"); + } + validateFetchUrl(url); + const context = await this.ensureReady(); + const page = this.firstOpenPage() ?? await context.newPage(); + const timeoutMs = normalizeTimeout(body.timeout_ms ?? this.sessionConfig.warmup_timeout_ms, this.appConfig.timeoutMs); + page.setDefaultTimeout(timeoutMs); + page.setDefaultNavigationTimeout(timeoutMs); + const response = await page.goto(url, { + waitUntil: body.wait_until ?? "domcontentloaded", + timeout: timeoutMs, + }); + if (!response) { + throw new HttpError(502, "no_response", "Warmup navigation produced no response"); + } + this.touchPage(page); + this.lastUsedAt = new Date().toISOString(); + return this.runtime(); + } + + async cookies(): Promise { + const context = await this.ensureReady(); + return context.cookies(); + } + + async pageList(): Promise { + const result: SourcePageInfo[] = []; + for (const [page, info] of this.pages) { + const title = info.closed ? null : await page.title().catch(() => null); + result.push({ + ...info, + title, + main_frame_url: info.closed ? null : page.mainFrame().url(), + }); + } + return result; + } + + async close(): Promise { + this.state = "closing"; + this.hub.broadcast("source.removed", this.runtime()); + const promise = this.startPromise; + this.startPromise = undefined; + const context = this.context; + this.context = undefined; + if (context) { + await context.close().catch(() => undefined); + } else if (promise) { + await promise.then((readyContext) => readyContext.close()).catch(() => undefined); + } + this.state = "closed"; + } + + beginGatewayRequest(): void { + this.requestCount += 1; + this.activeRequestCount += 1; + this.lastUsedAt = new Date().toISOString(); + } + + endGatewayRequest(): void { + this.activeRequestCount = Math.max(0, this.activeRequestCount - 1); + this.lastUsedAt = new Date().toISOString(); + } + + ensurePage(page: Page): string { + const existing = this.pages.get(page); + if (existing) { + return existing.page_id; + } + const info: MutablePageInfo = { + page_id: `${this.id}-page-${(++this.pageSeq).toString().padStart(4, "0")}`, + url: page.url(), + title: null, + main_frame_url: page.mainFrame().url(), + created_at: new Date().toISOString(), + last_activity_at: new Date().toISOString(), + closed: false, + }; + this.pages.set(page, info); + page.on("close", () => { + info.closed = true; + info.url = page.url(); + info.last_activity_at = new Date().toISOString(); + this.hub.broadcast("page.closed", info); + }); + page.on("framenavigated", () => this.touchPage(page)); + this.hub.broadcast("page.created", info); + return info.page_id; + } + + pageIdForPage(page: Page): string | null { + return this.pages.get(page)?.page_id ?? null; + } + + touchPage(page: Page): void { + const info = this.pages.get(page); + if (!info) { + return; + } + info.url = page.url(); + info.main_frame_url = page.mainFrame().url(); + info.last_activity_at = new Date().toISOString(); + } + + noteNetworkStarted(): void { + this.totalNetworkRequestCount += 1; + } + + noteNetworkFailed(): void { + this.totalNetworkFailedCount += 1; + } + + private async start(): Promise { + this.state = "starting"; + this.lastError = null; + this.hub.broadcast("source.starting", this.runtime()); + await fs.mkdir(this.profileDir, { recursive: true }); + try { + const context = await launchPersistentContext({ + userDataDir: this.profileDir, + headless: this.sessionConfig.headless ?? this.appConfig.headless, + proxy: this.proxy, + locale: this.sessionConfig.locale, + timezone: this.sessionConfig.timezone_id, + userAgent: this.sessionConfig.user_agent, + viewport: this.sessionConfig.viewport, + contextOptions: this.sessionConfig.extra_http_headers + ? { extraHTTPHeaders: sanitizeRequestHeaders(this.sessionConfig.extra_http_headers) } + : undefined, + }); + this.context = context; + this.state = "ready"; + this.readyAt = new Date().toISOString(); + this.startPromise = undefined; + this.observer = new NetworkObserver(this.id, this.appConfig.networkBodyCaptureBytes, this.networkHistory, this.hub, { + pageIdForPage: (page) => this.pageIdForPage(page), + ensurePage: (page) => this.ensurePage(page), + touchPage: (page) => this.touchPage(page), + noteNetworkStarted: () => this.noteNetworkStarted(), + noteNetworkFailed: () => this.noteNetworkFailed(), + }); + this.observer.attachContext(context); + if (context.pages().length === 0) { + await context.newPage(); + } + context.once("close", () => { + if (this.state !== "closing") { + this.state = "closed"; + this.context = undefined; + this.startPromise = undefined; + } + }); + context.browser()?.once("disconnected", () => { + this.state = "closed"; + this.context = undefined; + this.startPromise = undefined; + }); + this.hub.broadcast("source.ready", this.runtime()); + return context; + } catch (error) { + this.context = undefined; + this.startPromise = undefined; + this.state = "failed"; + this.lastError = error instanceof Error ? error.message : String(error); + this.hub.broadcast("source.failed", this.runtime()); + throw error; + } + } + + private firstOpenPage(): Page | undefined { + for (const [page, info] of this.pages) { + if (!info.closed) { + return page; + } + } + return undefined; + } + + private get proxy() { + return normalizeProxyConfig(this.sessionConfig.proxy ?? this.appConfig.proxy); + } +} + +interface MutablePageInfo extends SourcePageInfo {} + +function normalizeTimeout(rawTimeout: unknown, maxTimeoutMs: number): number { + if (rawTimeout === undefined || rawTimeout === null) { + return maxTimeoutMs; + } + if (typeof rawTimeout !== "number" || !Number.isInteger(rawTimeout) || rawTimeout < 1_000 || rawTimeout > maxTimeoutMs) { + throw new HttpError(400, "invalid_timeout", `timeout_ms must be an integer between 1000 and ${maxTimeoutMs}`); + } + return rawTimeout; +} diff --git a/src/source_session_manager.ts b/src/source_session_manager.ts new file mode 100644 index 0000000..f2aaecd --- /dev/null +++ b/src/source_session_manager.ts @@ -0,0 +1,114 @@ +import path from "node:path"; +import { sourceProfilePath, resolveProfilePath } from "./paths.js"; +import { SourceSession } from "./source_session.js"; +import { normalizeProxyConfig } from "./config.js"; +import type { NetworkHistory } from "./network_history.js"; +import type { WebSocketHub } from "./websocket_hub.js"; +import type { AppConfig, SourceSessionConfig, SourceSessionRuntime, WarmupRequestBody } from "./types.js"; +import { HttpError } from "./types.js"; + +const DEFAULT_SOURCE_ID = "default"; +const SOURCE_ID_RE = /^[A-Za-z0-9_.-]+$/; + +export class SourceSessionManager { + private readonly sessions = new Map(); + + constructor( + private readonly appConfig: AppConfig, + private readonly networkHistory: NetworkHistory, + private readonly hub: WebSocketHub, + ) { + this.sessions.set(DEFAULT_SOURCE_ID, this.buildSession({ id: DEFAULT_SOURCE_ID })); + } + + get defaultSourceId(): string { + return DEFAULT_SOURCE_ID; + } + + list(): SourceSessionRuntime[] { + return [...this.sessions.values()].map((session) => session.runtime()); + } + + get(id: string): SourceSession | undefined { + return this.sessions.get(id); + } + + require(id: string | undefined): SourceSession { + const sourceId = id ?? DEFAULT_SOURCE_ID; + const session = this.sessions.get(sourceId); + if (!session) { + throw new HttpError(404, "source_not_found", `SourceSession not found: ${sourceId}`); + } + return session; + } + + async create(rawConfig: SourceSessionConfig): Promise { + const config = normalizeSourceConfig(rawConfig, this.appConfig.profileDir); + if (this.sessions.has(config.id)) { + throw new HttpError(409, "source_exists", `SourceSession already exists: ${config.id}`); + } + const session = this.buildSession(config); + this.sessions.set(config.id, session); + this.hub.broadcast("source.created", session.runtime()); + if (config.warmup_url) { + await session.warmup({ url: config.warmup_url, timeout_ms: config.warmup_timeout_ms }); + } + return session.runtime(); + } + + async warmup(id: string, body: WarmupRequestBody): Promise { + return this.require(id).warmup(body); + } + + async remove(id: string): Promise { + validateSourceId(id); + const session = this.sessions.get(id); + if (!session) { + throw new HttpError(404, "source_not_found", `SourceSession not found: ${id}`); + } + await session.close(); + this.sessions.delete(id); + if (id === DEFAULT_SOURCE_ID) { + const replacement = this.buildSession({ id: DEFAULT_SOURCE_ID }); + this.sessions.set(DEFAULT_SOURCE_ID, replacement); + return replacement.runtime(); + } + return session.runtime(); + } + + async closeAll(): Promise { + await Promise.all([...this.sessions.values()].map((session) => session.close())); + } + + private buildSession(config: SourceSessionConfig): SourceSession { + const normalized = normalizeSourceConfig(config, this.appConfig.profileDir); + return new SourceSession(this.appConfig, normalized, this.networkHistory, this.hub); + } +} + +export function normalizeSourceConfig(rawConfig: SourceSessionConfig, profileRootDir: string): SourceSessionConfig { + validateSourceId(rawConfig.id); + if (rawConfig.viewport !== undefined) { + validateViewport(rawConfig.viewport); + } + const profileDir = rawConfig.profile_dir + ? resolveProfilePath(rawConfig.profile_dir) + : sourceProfilePath(profileRootDir, rawConfig.id); + return { + ...rawConfig, + profile_dir: profileDir, + proxy: normalizeProxyConfig(rawConfig.proxy), + }; +} + +export function validateSourceId(id: string): void { + if (!id || id === "." || id === ".." || !SOURCE_ID_RE.test(id) || id.includes("/") || id.includes("\\") || path.basename(id) !== id) { + throw new HttpError(400, "invalid_source_id", "source id may only contain letters, numbers, dot, underscore, and dash"); + } +} + +function validateViewport(viewport: { width: number; height: number }): void { + if (!Number.isInteger(viewport.width) || !Number.isInteger(viewport.height) || viewport.width < 1 || viewport.height < 1) { + throw new HttpError(400, "invalid_viewport", "viewport width and height must be positive integers"); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4fd2634 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,256 @@ +export type FetchMode = "document"; +export type CacheMode = "default" | "reload"; +export type SourceSessionState = "starting" | "ready" | "failed" | "closing" | "closed"; + +export interface ProxyConfig { + server: string; + username?: string; + password?: string; +} + +export interface AppConfig { + host: string; + port: number; + profileDir: string; + proxy?: ProxyConfig; + headless: boolean; + concurrency: number; + timeoutMs: number; + maxBodyBytes: number; + historyLimit: number; + websocketHeartbeatMs: number; + networkHistoryLimit: number; + networkBodyCaptureBytes: number; +} + +export interface FetchRequestBody { + url: string; + headers?: Record; + timeout_ms?: number; + cache_mode?: CacheMode; + source_id?: string; +} + +export interface FetchResult { + status: number; + headers: Record; + body: Buffer; + finalUrl: string; + mode: FetchMode; + cacheMode: CacheMode; + sourceId: string; + elapsedMs: number; +} + +export interface FetchStats { + lifetime_total: number; + lifetime_failed: number; + active: number; + queued: number; +} + +export type RequestKind = "fetch" | "diagnostics"; + +export interface RequestRecord { + id: string; + time: string; + kind: RequestKind; + fetch_mode: FetchMode; + cache_mode: CacheMode; + url: string; + host: string | null; + final_url: string | null; + status: number | null; + ok: boolean; + error_code: string | null; + error_message: string | null; + elapsed_ms: number; + body_size: number; + content_type: string | null; + proxy_configured: boolean; + proxy_url_masked: string | null; + source_id: string; +} + +export interface SourceSessionConfig { + id: string; + profile_dir?: string; + warmup_url?: string; + proxy?: ProxyConfig; + headless?: boolean; + locale?: string; + timezone_id?: string; + user_agent?: string; + viewport?: { width: number; height: number }; + extra_http_headers?: Record; + warmup_timeout_ms?: number; +} + +export interface WarmupRequestBody { + url?: string; + wait_until?: "load" | "domcontentloaded" | "networkidle" | "commit"; + timeout_ms?: number; +} + +export interface SourceSessionRuntime { + id: string; + state: SourceSessionState; + persistent: boolean; + profile_dir: string; + warmup_url: string | null; + current_url: string | null; + created_at: string; + ready_at: string | null; + last_used_at: string | null; + request_count: number; + page_count: number; + active_request_count: number; + total_network_request_count: number; + total_network_failed_count: number; + proxy_configured: boolean; + proxy_url_masked: string | null; + last_error: string | null; +} + +export interface SourcePageInfo { + page_id: string; + url: string; + title: string | null; + main_frame_url: string | null; + created_at: string; + last_activity_at: string; + closed: boolean; +} + +export interface CapturedBody { + size: number; + stored_bytes: number; + truncated: boolean; + encoding: "utf8" | "base64" | null; + text?: string; + base64?: string; +} + +export interface NetworkRecord { + id: string; + source_id: string; + page_id: string | null; + request_id: string; + parent_request_id: string | null; + started_at: string; + finished_at: string | null; + duration_ms: number | null; + url: string; + method: string; + resource_type: string; + navigation: boolean; + frame_url: string | null; + initiator: string | null; + request_headers: Record; + request_body_size: number; + request_post_data: CapturedBody | null; + status: number | null; + status_text: string | null; + protocol: string | null; + response_headers: Record | null; + response_body_size: number; + response_body: CapturedBody | null; + mime_type: string | null; + remote_address: { ip_address: string; port: number } | null; + from_cache: boolean; + service_worker: boolean; + failure_text: string | null; + redirect_from: string | null; + redirect_to: string | null; +} + +export interface NetworkRecentQuery { + limit?: string; + source_id?: string; + page_id?: string; + method?: string; + resource_type?: string; + status?: string; + host?: string; + failed?: string; + from_cache?: string; + text?: string; +} + +export interface NetworkSummary { + retained_total: number; + active: number; + finished: number; + failed: number; + by_method: Record; + by_resource_type: Record; + by_status: Record; + by_host: Record; + by_protocol: Record; + cache_hit_count: number; + total_request_bytes: number; + total_response_bytes: number; + avg_duration_ms: number; + p95_duration_ms: number; +} + +export interface MetricsSummary { + retained_total: number; + retained_success: number; + retained_failed: number; + active: number; + queued: number; + avg_elapsed_ms: number; + p95_elapsed_ms: number; + total_body_size: number; + status_counts: Record; + error_counts: Record; + host_counts: Record; + proxy_configured: boolean; + proxy_url_masked: string | null; +} + +export interface MetricsTimeseriesPoint { + time: string; + total: number; + success: number; + failed: number; + avg_elapsed_ms: number; + body_size: number; +} + +export interface DiagnosticsFetchResult { + url: string; + final_url: string | null; + status: number | null; + ok: boolean; + content_type: string | null; + body_size: number; + elapsed_ms: number; + fetch_mode: FetchMode; + error: string | null; +} + +export interface RequestLog { + request_id: string; + mode: FetchMode; + url: string; + host?: string; + status?: number; + body_size?: number; + elapsed_ms?: number; + final_url?: string; + error?: string; + source_id?: string; +} + +export class HttpError extends Error { + readonly statusCode: number; + readonly code: string; + + constructor(statusCode: number, code: string, message: string) { + super(message); + this.statusCode = statusCode; + this.code = code; + } +} diff --git a/src/url_redaction.ts b/src/url_redaction.ts new file mode 100644 index 0000000..6912e42 --- /dev/null +++ b/src/url_redaction.ts @@ -0,0 +1,28 @@ +const SENSITIVE_QUERY_KEYS = new Set([ + "token", + "access_token", + "api_key", + "key", + "signature", + "sig", + "auth", + "authorization", +]); + +export function redactUrlForLogs(rawUrl: string): string { + if (!rawUrl) { + return ""; + } + try { + const url = new URL(rawUrl); + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) { + url.searchParams.set(key, "***"); + } + } + return url.toString(); + } catch { + return rawUrl.split("#")[0] ?? rawUrl; + } +} diff --git a/src/websocket_hub.ts b/src/websocket_hub.ts new file mode 100644 index 0000000..30cb49b --- /dev/null +++ b/src/websocket_hub.ts @@ -0,0 +1,88 @@ +import type { WebSocket } from "@fastify/websocket"; + +const OPEN = 1; +const MAX_BUFFERED_BYTES = 5 * 1024 * 1024; + +export class WebSocketHub { + private readonly clients = new Set(); + private heartbeatTimer: ReturnType | undefined; + private sequence = 0; + private dropped = 0; + + constructor( + private readonly heartbeatMs: number, + private readonly getHeartbeatData: () => unknown, + ) {} + + get clientCount(): number { + return this.clients.size; + } + + get droppedEventCount(): number { + return this.dropped; + } + + addClient(client: WebSocket, helloData: unknown): void { + this.clients.add(client); + client.on("close", () => this.removeClient(client)); + client.on("error", () => this.removeClient(client)); + client.on("message", () => { + this.send(client, "error", { code: "unsupported_message", message: "Client commands are not supported" }); + }); + this.send(client, "hello", helloData); + } + + removeClient(client: WebSocket): void { + this.clients.delete(client); + } + + broadcast(type: string, data: unknown, detailed = false): void { + for (const client of [...this.clients]) { + this.send(client, type, data, detailed); + } + } + + startHeartbeat(): void { + if (this.heartbeatTimer) { + return; + } + this.heartbeatTimer = setInterval(() => { + this.broadcast("heartbeat", { + ...(this.getHeartbeatData() as object), + dropped_event_count: this.dropped, + }); + }, this.heartbeatMs); + this.heartbeatTimer.unref?.(); + } + + stopHeartbeat(): void { + if (!this.heartbeatTimer) { + return; + } + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + + private send(client: WebSocket, type: string, data: unknown, detailed = false): void { + if (client.readyState !== OPEN) { + this.removeClient(client); + return; + } + if (detailed && client.bufferedAmount > MAX_BUFFERED_BYTES) { + this.dropped += 1; + return; + } + const message = JSON.stringify({ + version: 1, + sequence: ++this.sequence, + type, + time: new Date().toISOString(), + data, + }); + client.send(message, (error: Error | undefined) => { + if (error) { + this.removeClient(client); + } + }); + } +} diff --git a/test/browser_integration.test.ts b/test/browser_integration.test.ts new file mode 100644 index 0000000..678d9dd --- /dev/null +++ b/test/browser_integration.test.ts @@ -0,0 +1,305 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; +import { Fetcher } from "../src/fetcher.js"; +import { NetworkHistory } from "../src/network_history.js"; +import { RequestHistory } from "../src/request_history.js"; +import { Semaphore } from "../src/semaphore.js"; +import { buildServer } from "../src/server.js"; +import { SourceSessionManager } from "../src/source_session_manager.js"; +import type { AppConfig, NetworkRecord } from "../src/types.js"; +import { WebSocketHub } from "../src/websocket_hub.js"; + +const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64"); + +test("real Chromium source sessions and network observer", { timeout: 120_000 }, async (t) => { + const target = await startTargetServer(); + const profileRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cloakfetch-test-profile-")); + const config = createConfig(profileRoot); + const history = new RequestHistory(config.historyLimit); + const networkHistory = new NetworkHistory(config.networkHistoryLimit); + let fetcher: Fetcher; + const hub = new WebSocketHub(config.websocketHeartbeatMs, () => ({ + server_time: new Date().toISOString(), + stats: fetcher.stats, + })); + const sources = new SourceSessionManager(config, networkHistory, hub); + fetcher = new Fetcher(config, sources, new Semaphore(config.concurrency)); + const gateway = await buildServer(config, fetcher, history, networkHistory, sources, hub); + await gateway.listen({ host: "127.0.0.1", port: 0 }); + const gatewayBase = `http://127.0.0.1:${(gateway.server.address() as { port: number }).port}`; + t.after(async () => { + await gateway.close(); + await sources.closeAll(); + await target.close(); + if (path.resolve(profileRoot).startsWith(os.tmpdir())) { + await fs.rm(profileRoot, { recursive: true, force: true }); + } + }); + + const ws = new WebSocket(gatewayBase.replace("http://", "ws://") + "/v1/ws"); + const wsMessages: unknown[] = []; + ws.onmessage = (event) => { + wsMessages.push(JSON.parse(event.data)); + }; + await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "hello")); + + await postJson(`${gatewayBase}/v1/sources`, { + id: "s1", + warmup_url: `${target.base}/set-cookie?value=a`, + viewport: { width: 1280, height: 720 }, + }); + await postJson(`${gatewayBase}/v1/sources`, { + id: "s2", + warmup_url: `${target.base}/set-cookie?value=b`, + }); + + const echo1 = await postFetchText(gatewayBase, `${target.base}/echo-cookie`, "s1"); + const echo2 = await postFetchText(gatewayBase, `${target.base}/echo-cookie`, "s2"); + assert.match(echo1, /session=a/); + assert.match(echo2, /session=b/); + + await postJson(`${gatewayBase}/v1/diagnostics/fetch`, { + source_id: "s1", + url: `${target.base}/`, + timeout_ms: 30000, + }); + await waitFor(() => hasPaths(networkHistory.recent(2000), ["/", "/script.js", "/style.css", "/image.png", "/frame", "/api?script=1"])); + assert.ok(target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("script") === "1")); + + await postJson(`${gatewayBase}/v1/sources/s1/warmup`, { + url: `${target.base}/localstorage-check`, + timeout_ms: 30000, + }); + await waitFor(() => target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("ls") === "a")); + + await postJson(`${gatewayBase}/v1/sources/s2/warmup`, { + url: `${target.base}/localstorage-check`, + timeout_ms: 30000, + }); + await waitFor(() => target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("ls") === "b")); + + await postJson(`${gatewayBase}/v1/diagnostics/fetch`, { + source_id: "s1", + url: `${target.base}/redirect`, + timeout_ms: 30000, + }); + await waitFor(() => networkHistory.recent(2000).some((record) => record.redirect_to?.endsWith("/image.png"))); + + await postJson(`${gatewayBase}/v1/diagnostics/fetch`, { + source_id: "s1", + url: `${target.base}/fail-page`, + timeout_ms: 30000, + }); + await waitFor(() => networkHistory.recent(2000).some((record) => record.failure_text && record.url.endsWith("/fail.png"))); + + await postFetchText(gatewayBase, `${target.base}/cache-page`, "s1"); + await postFetchText(gatewayBase, `${target.base}/cache-page`, "s1"); + await waitFor(() => networkHistory.recent(2000).some((record) => record.url.endsWith("/cache.png") && record.from_cache)); + + await postJson(`${gatewayBase}/v1/diagnostics/fetch`, { + source_id: "s1", + url: `${target.base}/many`, + timeout_ms: 30000, + }); + assert.ok(networkHistory.summary().retained_total > 20); + await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "network.request")); + await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "network.finished")); + + const imageResponse = await fetch(`${gatewayBase}/v1/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source_id: "s1", url: `${target.base}/image.png`, timeout_ms: 30000 }), + }); + const imageBody = Buffer.from(await imageResponse.arrayBuffer()); + assert.equal(imageResponse.status, 200); + assert.equal(imageBody[0], 0x89); + + const pages = await fetchJson(`${gatewayBase}/v1/sources/s1/pages`); + const cookies = await fetchJson(`${gatewayBase}/v1/sources/s1/cookies`); + assert.ok(pages.length > 0); + assert.ok(cookies.length > 0); + + await fetch(`${gatewayBase}/v1/sources/s2`, { method: "DELETE" }); + const removed = await fetch(`${gatewayBase}/v1/sources/s2`); + assert.equal(removed.status, 404); + ws.close(); +}); + +async function startTargetServer() { + const hits: URL[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + hits.push(url); + if (url.pathname === "/") { + send(res, 200, "text/html", `root`); + return; + } + if (url.pathname === "/script.js") { + send(res, 200, "application/javascript", `window.scriptLoaded = true; fetch('/api?script=1').catch(() => {});`); + return; + } + if (url.pathname === "/style.css") { + send(res, 200, "text/css", "body { color: rgb(1, 2, 3); }"); + return; + } + if (url.pathname === "/image.png") { + sendBuffer(res, 200, "image/png", PNG); + return; + } + if (url.pathname === "/frame") { + send(res, 200, "text/html", "frame

frame

"); + return; + } + if (url.pathname === "/api") { + send(res, 200, "application/json", JSON.stringify({ ok: true })); + return; + } + if (url.pathname === "/redirect") { + res.writeHead(302, { location: "/image.png" }); + res.end(); + return; + } + if (url.pathname === "/set-cookie") { + const value = url.searchParams.get("value") ?? "x"; + res.writeHead(200, { + "content-type": "text/html", + "set-cookie": `session=${value}; Path=/`, + }); + res.end(``); + return; + } + if (url.pathname === "/echo-cookie") { + send(res, 200, "text/plain", req.headers.cookie ?? ""); + return; + } + if (url.pathname === "/localstorage-check") { + send(res, 200, "text/html", ""); + return; + } + if (url.pathname === "/cache-page") { + send(res, 200, "text/html", ""); + return; + } + if (url.pathname === "/fail-page") { + send(res, 200, "text/html", ""); + return; + } + if (url.pathname === "/fail.png") { + req.socket.destroy(); + return; + } + if (url.pathname === "/cache.png") { + if (req.headers["if-none-match"] === "\"cache-test\"") { + res.writeHead(304, { etag: "\"cache-test\"", "cache-control": "no-cache" }); + res.end(); + return; + } + res.writeHead(200, { + "content-type": "image/png", + etag: "\"cache-test\"", + "cache-control": "no-cache", + "x-cache": "HIT", + }); + res.end(PNG); + return; + } + if (url.pathname === "/many") { + const images = Array.from({ length: 40 }, (_, index) => ``).join(""); + send(res, 200, "text/html", `${images}`); + return; + } + send(res, 404, "text/plain", "not found"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + return { + base: `http://127.0.0.1:${address.port}`, + hits, + close: async () => { + server.close(); + await once(server, "close").catch(() => undefined); + }, + }; +} + +function createConfig(profileDir: string): AppConfig { + return { + host: "127.0.0.1", + port: 0, + profileDir, + headless: true, + concurrency: 4, + timeoutMs: 30_000, + maxBodyBytes: 64 * 1024 * 1024, + historyLimit: 1000, + websocketHeartbeatMs: 1_000, + networkHistoryLimit: 5000, + networkBodyCaptureBytes: 262_144, + }; +} + +async function postFetchText(gatewayBase: string, url: string, sourceId: string): Promise { + const response = await fetch(`${gatewayBase}/v1/fetch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source_id: sourceId, url, timeout_ms: 30000 }), + }); + return response.text(); +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await response.text()}`); + } + return response.json() as Promise; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${response.status}: ${await response.text()}`); + } + return response.json() as Promise; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("waitFor timeout"); +} + +function hasPaths(records: NetworkRecord[], paths: string[]): boolean { + return paths.every((expected) => records.some((record) => { + try { + const url = new URL(record.url); + return `${url.pathname}${url.search}` === expected; + } catch { + return false; + } + })); +} + +function send(res: http.ServerResponse, status: number, contentType: string, body: string): void { + res.writeHead(status, { "content-type": contentType }); + res.end(body); +} + +function sendBuffer(res: http.ServerResponse, status: number, contentType: string, body: Buffer): void { + res.writeHead(status, { "content-type": contentType }); + res.end(body); +} diff --git a/test/unit.test.ts b/test/unit.test.ts new file mode 100644 index 0000000..a0795d6 --- /dev/null +++ b/test/unit.test.ts @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { loadConfig } from "../src/config.js"; +import { NetworkHistory } from "../src/network_history.js"; +import { RequestHistory } from "../src/request_history.js"; +import { normalizeSourceConfig, validateSourceId } from "../src/source_session_manager.js"; +import { sanitizeRequestHeaders, validateFetchUrl } from "../src/security.js"; +import { HttpError, type NetworkRecord, type RequestRecord } from "../src/types.js"; +import { createDebouncedTask } from "../web/src/debounce.ts"; + +test("service starts without host allowlist config and defaults to LAN listen address", () => { + const config = loadConfig({}); + assert.equal(config.host, "0.0.0.0"); + assert.equal(config.port, 9230); +}); + +test("CLOAKFETCH_HOST=0.0.0.0 and concrete IPs are accepted", () => { + assert.equal(loadConfig({ CLOAKFETCH_HOST: "0.0.0.0" }).host, "0.0.0.0"); + assert.equal(loadConfig({ CLOAKFETCH_HOST: "127.0.0.1" }).host, "127.0.0.1"); + assert.equal(loadConfig({ CLOAKFETCH_HOST: "::1" }).host, "::1"); +}); + +test("localhost and RFC1918 URLs pass URL format validation", () => { + assert.equal(validateFetchUrl("http://localhost:8080/").hostname, "localhost"); + assert.equal(validateFetchUrl("http://127.0.0.1:8080/").hostname, "127.0.0.1"); + assert.equal(validateFetchUrl("http://192.168.1.10/file").hostname, "192.168.1.10"); + assert.equal(validateFetchUrl("http://10.1.2.3/file").hostname, "10.1.2.3"); +}); + +test("URL validation keeps only absolute http and https format checks", () => { + assert.throws(() => validateFetchUrl("/relative"), (error) => error instanceof HttpError && error.code === "invalid_url"); + assert.throws(() => validateFetchUrl("file:///tmp/a"), (error) => error instanceof HttpError && error.code === "invalid_url_scheme"); +}); + +test("browser-controlled request headers stay forbidden", () => { + assert.throws(() => sanitizeRequestHeaders({ Host: "example.com" }), (error) => error instanceof HttpError && error.code === "forbidden_header"); + assert.throws(() => sanitizeRequestHeaders({ "Sec-Fetch-Dest": "document" }), (error) => error instanceof HttpError && error.code === "forbidden_header"); +}); + +test("source id rejects path traversal and separators", () => { + assert.doesNotThrow(() => validateSourceId("arcgis.default-1")); + assert.throws(() => validateSourceId("../x"), (error) => error instanceof HttpError && error.code === "invalid_source_id"); + assert.throws(() => validateSourceId("a\\b"), (error) => error instanceof HttpError && error.code === "invalid_source_id"); +}); + +test("source profile defaults under profile/sources/source_id", () => { + const config = normalizeSourceConfig({ id: "arcgis" }, "D:\\root\\profile"); + assert.equal(config.profile_dir, "D:\\root\\profile\\sources\\arcgis"); +}); + +test("RequestHistory retained stats keep new field names", () => { + const history = new RequestHistory(10); + history.add(makeRequestRecord({ id: "1", ok: true, status: 200, body_size: 10 })); + history.add(makeRequestRecord({ id: "2", ok: false, status: 503, error_code: "http_503" })); + const summary = history.summary({ + lifetime_total: 99, + lifetime_failed: 1, + active: 1, + queued: 2, + }, false, null); + assert.equal(summary.retained_total, 2); + assert.equal(summary.retained_success, 1); + assert.equal(summary.retained_failed, 1); + assert.equal((summary as unknown as { total?: number }).total, undefined); +}); + +test("NetworkHistory summary counts retained Chromium records", () => { + const history = new NetworkHistory(10); + history.upsert(makeNetworkRecord({ id: "n1", method: "GET", resource_type: "document", status: 200, response_body_size: 100, duration_ms: 10, finished_at: new Date().toISOString() })); + history.upsert(makeNetworkRecord({ id: "n2", method: "GET", resource_type: "image", status: 304, response_body_size: 0, duration_ms: 4, from_cache: true, finished_at: new Date().toISOString() })); + history.upsert(makeNetworkRecord({ id: "n3", method: "POST", resource_type: "fetch", status: null, failure_text: "net::ERR_FAILED", finished_at: new Date().toISOString() })); + const summary = history.summary(); + assert.equal(summary.retained_total, 3); + assert.equal(summary.finished, 3); + assert.equal(summary.failed, 1); + assert.equal(summary.cache_hit_count, 1); + assert.deepEqual(summary.by_resource_type, { document: 1, image: 1, fetch: 1 }); +}); + +test("debounced task collapses multiple schedules into one run", async () => { + let runs = 0; + const task = createDebouncedTask(() => { + runs += 1; + }, 20); + task.schedule(); + task.schedule(); + task.schedule(); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.equal(runs, 1); + task.cancel(); +}); + +function makeRequestRecord(partial: Partial): RequestRecord { + return { + id: partial.id ?? "id", + time: partial.time ?? new Date().toISOString(), + kind: partial.kind ?? "fetch", + fetch_mode: "document", + cache_mode: partial.cache_mode ?? "default", + url: partial.url ?? "http://127.0.0.1/file", + host: partial.host ?? "127.0.0.1", + final_url: partial.final_url === undefined ? "http://127.0.0.1/file" : partial.final_url, + status: partial.status === undefined ? 200 : partial.status, + ok: partial.ok ?? true, + error_code: partial.error_code ?? null, + error_message: partial.error_message ?? null, + elapsed_ms: partial.elapsed_ms ?? 1, + body_size: partial.body_size ?? 0, + content_type: partial.content_type ?? "text/plain", + proxy_configured: partial.proxy_configured ?? false, + proxy_url_masked: partial.proxy_url_masked ?? null, + source_id: partial.source_id ?? "default", + }; +} + +function makeNetworkRecord(partial: Partial): NetworkRecord { + return { + id: partial.id ?? "net", + source_id: partial.source_id ?? "default", + page_id: partial.page_id ?? "page", + request_id: partial.request_id ?? partial.id ?? "net", + parent_request_id: partial.parent_request_id ?? null, + started_at: partial.started_at ?? new Date().toISOString(), + finished_at: partial.finished_at ?? null, + duration_ms: partial.duration_ms ?? null, + url: partial.url ?? "http://127.0.0.1/", + method: partial.method ?? "GET", + resource_type: partial.resource_type ?? "document", + navigation: partial.navigation ?? false, + frame_url: partial.frame_url ?? null, + initiator: partial.initiator ?? null, + request_headers: partial.request_headers ?? {}, + request_body_size: partial.request_body_size ?? 0, + request_post_data: partial.request_post_data ?? null, + status: partial.status ?? null, + status_text: partial.status_text ?? null, + protocol: partial.protocol ?? null, + response_headers: partial.response_headers ?? null, + response_body_size: partial.response_body_size ?? 0, + response_body: partial.response_body ?? null, + mime_type: partial.mime_type ?? null, + remote_address: partial.remote_address ?? null, + from_cache: partial.from_cache ?? false, + service_worker: partial.service_worker ?? false, + failure_text: partial.failure_text ?? null, + redirect_from: partial.redirect_from ?? null, + redirect_to: partial.redirect_to ?? null, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff0d38c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c120929 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + CloakFetch Gateway Console + + +
+ + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..9a00848 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,296 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + clearNetworkHistory, + getHealth, + getNetworkRecent, + getNetworkSummary, + getRecentRequests, + getRuntime, + getSourceCookies, + getSourcePages, + getSources, + getSummary, + getTimeseries, + removeSource, + warmupSource, +} from "./api"; +import { DistributionPanel } from "./components/DistributionPanel"; +import { NetworkInspector } from "./components/NetworkInspector"; +import { RequestTable } from "./components/RequestTable"; +import { RuntimePanel } from "./components/RuntimePanel"; +import { SourceSessionsPanel } from "./components/SourceSessionsPanel"; +import { SummaryPanel } from "./components/SummaryPanel"; +import { TimeseriesPanel } from "./components/TimeseriesPanel"; +import { createDebouncedTask } from "./debounce"; +import type { MetricsSummary, NetworkRecord, NetworkSummary, RequestRecord, RuntimeInfo, SourceSessionRuntime, TimeseriesPoint } from "./types"; +import { connectGatewaySocket, type WebSocketStatus } from "./ws"; + +export function App() { + const [runtime, setRuntime] = useState(null); + const [summary, setSummary] = useState(null); + const [timeseries, setTimeseries] = useState([]); + const [requests, setRequests] = useState([]); + const [sources, setSources] = useState([]); + const [cookieCounts, setCookieCounts] = useState>({}); + const [sourceDetailTitle, setSourceDetailTitle] = useState(null); + const [sourceDetailData, setSourceDetailData] = useState(null); + const [networkSummary, setNetworkSummary] = useState(null); + const [networkRecords, setNetworkRecords] = useState([]); + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [networkPaused, setNetworkPaused] = useState(false); + const [httpOnline, setHttpOnline] = useState(false); + const [wsStatus, setWsStatus] = useState("disconnected"); + const [lastHeartbeat, setLastHeartbeat] = useState(null); + const [error, setError] = useState(null); + const networkQueueRef = useRef>(new Map()); + const networkFlushTimerRef = useRef(undefined); + const networkPausedRef = useRef(false); + + const refreshRuntime = useCallback(async () => { + setRuntime(await getRuntime()); + }, []); + + const refreshMetrics = useCallback(async () => { + const [nextSummary, nextTimeseries] = await Promise.all([ + getSummary(), + getTimeseries(60), + ]); + setSummary(nextSummary); + setTimeseries(nextTimeseries); + }, []); + + const refreshNetwork = useCallback(async () => { + const [nextNetworkSummary, nextNetworkRecords] = await Promise.all([ + getNetworkSummary(), + getNetworkRecent({ limit: "500" }), + ]); + setNetworkSummary(nextNetworkSummary); + setNetworkRecords(nextNetworkRecords.slice(0, 5000)); + }, []); + + const refreshSources = useCallback(async () => { + setSources(await getSources()); + }, []); + + const refreshSnapshot = useCallback(async () => { + try { + const health = await getHealth(); + setHttpOnline(health.status === "ok"); + const [nextRuntime, nextSummary, nextTimeseries, nextRequests, nextSources, nextNetworkSummary, nextNetworkRecords] = await Promise.all([ + getRuntime(), + getSummary(), + getTimeseries(60), + getRecentRequests(200), + getSources(), + getNetworkSummary(), + getNetworkRecent({ limit: "500" }), + ]); + setRuntime(nextRuntime); + setSummary(nextSummary); + setTimeseries(nextTimeseries); + setRequests(nextRequests.slice(0, 500)); + setSources(nextSources); + setNetworkSummary(nextNetworkSummary); + setNetworkRecords(nextNetworkRecords.slice(0, 5000)); + setError(null); + } catch (cause) { + setHttpOnline(false); + setError(errorMessage(cause)); + } + }, []); + + const flushNetworkQueue = useCallback(() => { + const queued = [...networkQueueRef.current.values()]; + networkQueueRef.current.clear(); + networkFlushTimerRef.current = undefined; + if (queued.length === 0) { + return; + } + setNetworkRecords((current) => mergeNetworkRecords(current, queued)); + setSelectedNetwork((current) => { + if (!current) { + return current; + } + return queued.find((record) => record.id === current.id) ?? current; + }); + }, []); + + const queueNetworkRecord = useCallback((record: NetworkRecord) => { + if (networkPausedRef.current) { + return; + } + networkQueueRef.current.set(record.id, record); + if (networkFlushTimerRef.current !== undefined) { + return; + } + networkFlushTimerRef.current = window.setTimeout(flushNetworkQueue, 150); + }, [flushNetworkQueue]); + + useEffect(() => { + networkPausedRef.current = networkPaused; + }, [networkPaused]); + + useEffect(() => { + void refreshSnapshot(); + const debouncedMetrics = createDebouncedTask(() => { + void refreshMetrics().catch((cause) => setError(errorMessage(cause))); + }, 500); + const debouncedNetwork = createDebouncedTask(() => { + void getNetworkSummary().then(setNetworkSummary).catch((cause) => setError(errorMessage(cause))); + }, 500); + const debouncedSources = createDebouncedTask(() => { + void refreshSources().catch((cause) => setError(errorMessage(cause))); + }, 500); + const socket = connectGatewaySocket({ + onStatus: setWsStatus, + onHello(payload) { + setLastHeartbeat(payload.server_time); + setSummary(payload.summary); + setNetworkSummary(payload.network_summary); + setSources(payload.sources); + void refreshRuntime().catch((cause) => setError(errorMessage(cause))); + }, + onRequest(record) { + setRequests((current) => [record, ...current.filter((item) => item.id !== record.id)].slice(0, 500)); + debouncedMetrics.schedule(); + }, + onNetwork(record) { + queueNetworkRecord(record); + debouncedNetwork.schedule(); + }, + onSource(record) { + setSources((current) => [record, ...current.filter((item) => item.id !== record.id)].sort((a, b) => a.id.localeCompare(b.id))); + debouncedSources.schedule(); + }, + onPage() { + debouncedSources.schedule(); + }, + onHeartbeat(payload) { + setLastHeartbeat(payload.server_time); + }, + onError(message) { + setError(message); + }, + }); + return () => { + debouncedMetrics.cancel(); + debouncedNetwork.cancel(); + debouncedSources.cancel(); + if (networkFlushTimerRef.current !== undefined) { + window.clearTimeout(networkFlushTimerRef.current); + } + socket.close(); + }; + }, [queueNetworkRecord, refreshMetrics, refreshRuntime, refreshSnapshot, refreshSources]); + + const handleWarmup = useCallback((id: string) => { + const current = sources.find((source) => source.id === id); + const url = window.prompt("Warmup URL", current?.warmup_url ?? current?.current_url ?? ""); + if (url === null) { + return; + } + void warmupSource(id, url || undefined).then((source) => { + setSources((items) => [source, ...items.filter((item) => item.id !== source.id)].sort((a, b) => a.id.localeCompare(b.id))); + }).catch((cause) => setError(errorMessage(cause))); + }, [sources]); + + const handleRemove = useCallback((id: string) => { + void removeSource(id).then(() => refreshSources()).catch((cause) => setError(errorMessage(cause))); + }, [refreshSources]); + + const handleViewCookies = useCallback((id: string) => { + void getSourceCookies(id).then((cookies) => { + setCookieCounts((current) => ({ ...current, [id]: cookies.length })); + setSourceDetailTitle(`${id} cookies`); + setSourceDetailData(cookies); + }).catch((cause) => setError(errorMessage(cause))); + }, []); + + const handleOpenPages = useCallback((id: string) => { + void getSourcePages(id).then((pages) => { + setSourceDetailTitle(`${id} pages`); + setSourceDetailData(pages); + }).catch((cause) => setError(errorMessage(cause))); + }, []); + + const handleClearServerNetwork = useCallback(() => { + void clearNetworkHistory().then(() => { + setNetworkRecords([]); + setSelectedNetwork(null); + void refreshNetwork(); + }).catch((cause) => setError(errorMessage(cause))); + }, [refreshNetwork]); + + return ( +
+
+
+

CloakFetch Gateway Console

+

LAN CloakBrowser network execution service

+
+
+ + HTTP {httpOnline ? "online" : "offline"} + + WebSocket {wsStatus} +
+
+ {error &&
{error}
} +
+ + +
+ + + + +
+ + + +
+ + { + setNetworkRecords([]); + setSelectedNetwork(null); + }} + onClearServer={handleClearServerNetwork} + onSelect={setSelectedNetwork} + /> +
+ ); +} + +function mergeNetworkRecords(current: NetworkRecord[], incoming: NetworkRecord[]): NetworkRecord[] { + const byId = new Map(); + for (const record of incoming) { + byId.set(record.id, record); + } + for (const record of current) { + if (!byId.has(record.id)) { + byId.set(record.id, record); + } + } + return [...byId.values()] + .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at)) + .slice(0, 5000); +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : "Unknown error"; +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..0b7db78 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,87 @@ +import type { + MetricsSummary, + NetworkBodyResponse, + NetworkRecord, + NetworkSummary, + RequestRecord, + RuntimeInfo, + SourcePageInfo, + SourceSessionRuntime, + TimeseriesPoint, +} from "./types"; + +export async function getHealth(): Promise<{ status: string }> { + return fetchJson("/health"); +} + +export async function getRuntime(): Promise { + return fetchJson("/v1/diagnostics/runtime"); +} + +export async function getSummary(): Promise { + return fetchJson("/v1/metrics/summary"); +} + +export async function getTimeseries(windowSec = 60): Promise { + return fetchJson(`/v1/metrics/timeseries?window_sec=${windowSec}`); +} + +export async function getRecentRequests(limit = 200): Promise { + return fetchJson(`/v1/requests/recent?limit=${limit}`); +} + +export async function getSources(): Promise { + return fetchJson("/v1/sources"); +} + +export async function warmupSource(id: string, url?: string): Promise { + return fetchJson(`/v1/sources/${encodeURIComponent(id)}/warmup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(url ? { url } : {}), + }); +} + +export async function removeSource(id: string): Promise { + return fetchJson(`/v1/sources/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export async function getSourceCookies(id: string): Promise { + return fetchJson(`/v1/sources/${encodeURIComponent(id)}/cookies`); +} + +export async function getSourcePages(id: string): Promise { + return fetchJson(`/v1/sources/${encodeURIComponent(id)}/pages`); +} + +export async function getNetworkRecent(query: Record = {}): Promise { + const params = new URLSearchParams({ limit: query.limit ?? "500", ...query }); + return fetchJson(`/v1/network/recent?${params}`); +} + +export async function getNetworkSummary(): Promise { + return fetchJson("/v1/network/summary"); +} + +export async function getNetworkBody(id: string): Promise { + return fetchJson(`/v1/network/${encodeURIComponent(id)}/body`); +} + +export async function clearNetworkHistory(): Promise<{ status: string }> { + return fetchJson("/v1/network/history", { method: "DELETE" }); +} + +async function fetchJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { + accept: "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${message}`); + } + return response.json() as Promise; +} diff --git a/web/src/components/DistributionPanel.tsx b/web/src/components/DistributionPanel.tsx new file mode 100644 index 0000000..0193682 --- /dev/null +++ b/web/src/components/DistributionPanel.tsx @@ -0,0 +1,31 @@ +interface DistributionPanelProps { + title: string; + counts: Record; +} + +export function DistributionPanel({ title, counts }: DistributionPanelProps) { + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 12); + const max = Math.max(1, ...entries.map((entry) => entry[1])); + return ( +
+
+

{title}

+
+ {entries.length === 0 ? ( +
No data
+ ) : ( +
+ {entries.map(([label, value]) => ( +
+
{label}
+
+
+
+
{value}
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/components/MetricCard.tsx b/web/src/components/MetricCard.tsx new file mode 100644 index 0000000..6c3f22f --- /dev/null +++ b/web/src/components/MetricCard.tsx @@ -0,0 +1,14 @@ +interface MetricCardProps { + label: string; + value: string | number; + tone?: "neutral" | "success" | "failed" | "active"; +} + +export function MetricCard({ label, value, tone = "neutral" }: MetricCardProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/components/NetworkInspector.tsx b/web/src/components/NetworkInspector.tsx new file mode 100644 index 0000000..85c5e25 --- /dev/null +++ b/web/src/components/NetworkInspector.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from "react"; +import type { CapturedBody, NetworkRecord, NetworkSummary } from "../types"; +import { formatBytes } from "./SummaryPanel"; + +interface NetworkInspectorProps { + records: NetworkRecord[]; + summary: NetworkSummary | null; + selected: NetworkRecord | null; + paused: boolean; + onPauseChange: (paused: boolean) => void; + onClearLocal: () => void; + onClearServer: () => void; + onSelect: (record: NetworkRecord) => void; +} + +type BoolFilter = "all" | "yes" | "no"; + +export function NetworkInspector({ + records, + summary, + selected, + paused, + onPauseChange, + onClearLocal, + onClearServer, + onSelect, +}: NetworkInspectorProps) { + const [source, setSource] = useState(""); + const [page, setPage] = useState(""); + const [method, setMethod] = useState(""); + const [status, setStatus] = useState(""); + const [resourceType, setResourceType] = useState(""); + const [host, setHost] = useState(""); + const [failed, setFailed] = useState("all"); + const [cache, setCache] = useState("all"); + const [text, setText] = useState(""); + const filtered = useMemo(() => { + const query = text.trim().toLowerCase(); + return records.filter((record) => { + const recordHost = safeHost(record.url); + const matchesSource = !source || record.source_id.includes(source); + const matchesPage = !page || (record.page_id ?? "").includes(page); + const matchesMethod = !method || record.method.toLowerCase().includes(method.toLowerCase()); + const matchesStatus = !status || String(record.status ?? "").includes(status); + const matchesType = !resourceType || record.resource_type.toLowerCase().includes(resourceType.toLowerCase()); + const matchesHost = !host || (recordHost ?? "").toLowerCase().includes(host.toLowerCase()); + const matchesFailed = failed === "all" || (failed === "yes" ? Boolean(record.failure_text) : !record.failure_text); + const matchesCache = cache === "all" || (cache === "yes" ? record.from_cache : !record.from_cache); + const matchesText = !query || [record.url, record.method, record.resource_type, record.failure_text ?? ""].some((value) => value.toLowerCase().includes(query)); + return matchesSource && matchesPage && matchesMethod && matchesStatus && matchesType && matchesHost && matchesFailed && matchesCache && matchesText; + }); + }, [records, source, page, method, status, resourceType, host, failed, cache, text]); + return ( +
+
+

Network Inspector

+
+ + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + {filtered.map((record) => ( + onSelect(record)}> + + + + + + + + + + + + + + + + ))} + +
timesourcepagemethodstatustypeprotocolcachedurationrequest bytesresponse byteshosturlfailure
{new Date(record.started_at).toLocaleTimeString()}{record.source_id}{record.page_id ?? "-"}{record.method}{record.status ?? "-"}{record.resource_type}{record.protocol ?? "-"}{record.from_cache ? "yes" : "no"}{record.duration_ms === null ? "-" : `${record.duration_ms} ms`}{formatBytes(record.request_body_size)}{formatBytes(record.response_body_size)}{safeHost(record.url) ?? "-"}{record.url}{record.failure_text ?? "-"}
+
+ {selected && } +
+ ); +} + +function NetworkDetails({ record }: { record: NetworkRecord }) { + return ( +
+

{record.method} {record.url}

+
+ + + + + + + + + +
+
+ ); +} + +function Detail({ title, value }: { title: string; value: unknown }) { + return ( +
+

{title}

+
{JSON.stringify(value, null, 2)}
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function renderBody(body: CapturedBody | null): unknown { + if (!body) { + return null; + } + return body; +} + +function safeHost(rawUrl: string): string | null { + try { + return new URL(rawUrl).hostname; + } catch { + return null; + } +} diff --git a/web/src/components/RequestTable.tsx b/web/src/components/RequestTable.tsx new file mode 100644 index 0000000..b7d1a41 --- /dev/null +++ b/web/src/components/RequestTable.tsx @@ -0,0 +1,93 @@ +import { useMemo, useState } from "react"; +import type { RequestRecord } from "../types"; +import { formatBytes } from "./SummaryPanel"; + +interface RequestTableProps { + records: RequestRecord[]; +} + +type OkFilter = "all" | "success" | "failed"; + +export function RequestTable({ records }: RequestTableProps) { + const [text, setText] = useState(""); + const [okFilter, setOkFilter] = useState("all"); + const [status, setStatus] = useState(""); + const [errorCode, setErrorCode] = useState(""); + const filtered = useMemo(() => { + const query = text.trim().toLowerCase(); + const errorQuery = errorCode.trim().toLowerCase(); + return records.filter((record) => { + const matchesText = !query || record.url.toLowerCase().includes(query) || (record.host ?? "").toLowerCase().includes(query); + const matchesOk = okFilter === "all" || (okFilter === "success" ? record.ok : !record.ok); + const matchesStatus = !status.trim() || String(record.status ?? "").includes(status.trim()); + const matchesError = !errorQuery || (record.error_code ?? "").toLowerCase().includes(errorQuery); + return matchesText && matchesOk && matchesStatus && matchesError; + }); + }, [records, text, okFilter, status, errorCode]); + return ( +
+
+

Request Log

+
{filtered.length} / {records.length}
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + {filtered.map((record) => ( + + + + + + + + + + + + + + ))} + +
timekindcachehoststatusokelapsedbodyerror_codeproxyurl
{new Date(record.time).toLocaleTimeString()}{record.kind}{record.cache_mode}{record.host ?? "-"}{record.status ?? "-"}{record.ok ? "yes" : "no"}{record.elapsed_ms} ms{formatBytes(record.body_size)}{record.error_code ?? "-"}{record.proxy_configured ? record.proxy_url_masked ?? "configured" : "direct"}{record.url}
+
+
+ ); +} diff --git a/web/src/components/RuntimePanel.tsx b/web/src/components/RuntimePanel.tsx new file mode 100644 index 0000000..41675e9 --- /dev/null +++ b/web/src/components/RuntimePanel.tsx @@ -0,0 +1,53 @@ +import type { RuntimeInfo } from "../types"; +import type { WebSocketStatus } from "../ws"; + +interface RuntimePanelProps { + runtime: RuntimeInfo | null; + httpOnline: boolean; + wsStatus: WebSocketStatus; + lastHeartbeat: string | null; +} + +export function RuntimePanel({ runtime, httpOnline, wsStatus, lastHeartbeat }: RuntimePanelProps) { + return ( +
+
+

Runtime

+
+
+ + + + + + + + + + + + + + + + +
+
+ ); +} + +interface RuntimeItemProps { + label: string; + value: string | number; + tone?: "neutral" | "success" | "failed" | "active"; + wide?: boolean; +} + +function RuntimeItem({ label, value, tone = "neutral", wide = false }: RuntimeItemProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/components/SourceSessionsPanel.tsx b/web/src/components/SourceSessionsPanel.tsx new file mode 100644 index 0000000..cebf205 --- /dev/null +++ b/web/src/components/SourceSessionsPanel.tsx @@ -0,0 +1,83 @@ +import type { SourceSessionRuntime } from "../types"; + +interface SourceSessionsPanelProps { + sources: SourceSessionRuntime[]; + cookieCounts: Record; + detailTitle: string | null; + detailData: unknown; + onWarmup: (id: string) => void; + onRemove: (id: string) => void; + onViewCookies: (id: string) => void; + onOpenPages: (id: string) => void; +} + +export function SourceSessionsPanel({ + sources, + cookieCounts, + detailTitle, + detailData, + onWarmup, + onRemove, + onViewCookies, + onOpenPages, +}: SourceSessionsPanelProps) { + return ( +
+
+

Source Sessions

+
{sources.length} sources
+
+
+ + + + + + + + + + + + + + + + + + + + {sources.map((source) => ( + + + + + + + + + + + + + + + + ))} + +
idstatecurrent_urlwarmup_urlcookiespagesactivetotal requestfailed requestproxylast usedlast erroractions
{source.id}{source.state}{source.current_url ?? "-"}{source.warmup_url ?? "-"}{cookieCounts[source.id] ?? "-"}{source.page_count}{source.active_request_count}{source.total_network_request_count}{source.total_network_failed_count}{source.proxy_configured ? source.proxy_url_masked ?? "configured" : "direct"}{source.last_used_at ? new Date(source.last_used_at).toLocaleTimeString() : "-"}{source.last_error ?? "-"} + + + + +
+
+ {detailTitle && ( +
+

{detailTitle}

+
{JSON.stringify(detailData, null, 2)}
+
+ )} +
+ ); +} diff --git a/web/src/components/SummaryPanel.tsx b/web/src/components/SummaryPanel.tsx new file mode 100644 index 0000000..8dd7412 --- /dev/null +++ b/web/src/components/SummaryPanel.tsx @@ -0,0 +1,43 @@ +import type { MetricsSummary } from "../types"; +import { MetricCard } from "./MetricCard"; + +interface SummaryPanelProps { + summary: MetricsSummary | null; +} + +export function SummaryPanel({ summary }: SummaryPanelProps) { + const total = summary?.retained_total ?? 0; + const success = summary?.retained_success ?? 0; + const successRate = total === 0 ? "0%" : `${Math.round((success / total) * 100)}%`; + return ( +
+
+

Summary

+
+
+ + + + + + + + + +
+
+ ); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + } + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; +} diff --git a/web/src/components/TimeseriesPanel.tsx b/web/src/components/TimeseriesPanel.tsx new file mode 100644 index 0000000..d78641f --- /dev/null +++ b/web/src/components/TimeseriesPanel.tsx @@ -0,0 +1,43 @@ +import type { TimeseriesPoint } from "../types"; + +interface TimeseriesPanelProps { + points: TimeseriesPoint[]; +} + +export function TimeseriesPanel({ points }: TimeseriesPanelProps) { + const width = 720; + const height = 180; + const padding = 20; + const chartHeight = height - padding * 2; + const maxValue = Math.max(1, ...points.map((point) => point.total)); + const barWidth = points.length === 0 ? 0 : (width - padding * 2) / points.length; + return ( +
+
+

Last 60 Seconds

+
+ total + success + failed +
+
+ + + {points.map((point, index) => { + const x = padding + index * barWidth; + const totalHeight = (point.total / maxValue) * chartHeight; + const successHeight = (point.success / maxValue) * chartHeight; + const failedHeight = (point.failed / maxValue) * chartHeight; + return ( + + {`${new Date(point.time).toLocaleTimeString()} total=${point.total} success=${point.success} failed=${point.failed}`} + + + + + ); + })} + +
+ ); +} diff --git a/web/src/debounce.ts b/web/src/debounce.ts new file mode 100644 index 0000000..42a0773 --- /dev/null +++ b/web/src/debounce.ts @@ -0,0 +1,26 @@ +export interface DebouncedTask { + schedule: () => void; + cancel: () => void; +} + +export function createDebouncedTask(task: () => void, delayMs: number): DebouncedTask { + let timer: ReturnType | undefined; + return { + schedule() { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = undefined; + task(); + }, delayMs); + }, + cancel() { + if (timer === undefined) { + return; + } + clearTimeout(timer); + timer = undefined; + }, + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..80507c5 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./style.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..8c0d94c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,482 @@ +:root { + color: #1f2933; + background: #f4f6f8; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f4f6f8; +} + +main { + width: min(1480px, calc(100vw - 32px)); + margin: 0 auto; + padding: 24px 0 40px; +} + +h1, h2, p { + margin: 0; +} + +h1 { + font-size: 26px; + font-weight: 700; +} + +h2 { + font-size: 16px; + font-weight: 700; +} + +button, input, select { + font: inherit; +} + +button { + border: 1px solid #205493; + border-radius: 6px; + background: #205493; + color: #fff; + padding: 8px 12px; + cursor: pointer; +} + +button:hover { + background: #183f70; +} + +.button-secondary { + border-color: #c8d1dc; + background: #fff; + color: #25313f; +} + +.button-secondary:hover { + background: #eef2f6; +} + +.network-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.inline-control { + display: inline-flex; + gap: 6px; + align-items: center; + color: #526274; +} + +.app-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 16px; +} + +.app-header p { + margin-top: 6px; + color: #5f6f82; +} + +.header-status { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 280px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #d19a2a; +} + +.status-dot--success { + background: #2e7d55; +} + +.status-dot--failed { + background: #b54a4a; +} + +.status-dot--active { + background: #2773b8; +} + +.error-banner { + border: 1px solid #e1b2b2; + border-radius: 8px; + background: #fff1f1; + color: #8f3030; + padding: 10px 12px; + margin-bottom: 16px; +} + +.actions { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.panel { + border: 1px solid #d8e0e8; + border-radius: 8px; + background: #fff; + padding: 16px; + margin-bottom: 16px; +} + +.panel__header { + margin-bottom: 12px; +} + +.panel__header--row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.runtime-grid, .metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.metrics-grid--compact { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + margin-bottom: 12px; +} + +.runtime-item, .metric-card { + border: 1px solid #dfe6ee; + border-radius: 8px; + background: #f9fafb; + padding: 10px; + min-width: 0; +} + +.runtime-item--wide { + grid-column: span 2; +} + +.runtime-item__label, .metric-card__label { + color: #6b7b8d; + font-size: 12px; + margin-bottom: 6px; +} + +.runtime-item__value, .metric-card__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; +} + +.metric-card__value { + font-size: 22px; +} + +.metric-card--success { + border-color: #b9dacb; + background: #f0f8f4; +} + +.metric-card--failed { + border-color: #e4bcbc; + background: #fff5f5; +} + +.metric-card--active { + border-color: #bdd4ea; + background: #f2f7fc; +} + +.runtime-item--success { + border-color: #b9dacb; +} + +.runtime-item--failed { + border-color: #e4bcbc; +} + +.runtime-item--active { + border-color: #bdd4ea; +} + +.chart-legend { + display: flex; + gap: 12px; + color: #5f6f82; + font-size: 12px; +} + +.legend::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 5px; +} + +.legend--total::before { + background: #c7d0da; +} + +.legend--success::before { + background: #2e7d55; +} + +.legend--failed::before { + background: #b54a4a; +} + +.timeseries-chart { + display: block; + width: 100%; + height: auto; +} + +.axis { + stroke: #ccd6e0; + stroke-width: 1; +} + +.bar-total { + fill: #c7d0da; +} + +.bar-success { + fill: #2e7d55; +} + +.bar-failed { + fill: #b54a4a; +} + +.distribution-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.distribution-panel { + min-width: 0; +} + +.empty { + color: #7a8796; +} + +.bar-list { + display: grid; + gap: 8px; +} + +.bar-row { + display: grid; + grid-template-columns: minmax(80px, 1fr) 2fr 40px; + gap: 8px; + align-items: center; +} + +.bar-row__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bar-row__track { + height: 10px; + border-radius: 4px; + background: #edf1f5; + overflow: hidden; +} + +.bar-row__fill { + height: 100%; + background: #2773b8; +} + +.bar-row__value { + text-align: right; + color: #5f6f82; +} + +.request-panel { + overflow: hidden; +} + +.request-count { + color: #5f6f82; +} + +.filters { + display: grid; + grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.filters--network { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +} + +.filters label { + display: grid; + gap: 5px; + color: #5f6f82; + font-size: 12px; +} + +.filters input, .filters select { + border: 1px solid #c8d1dc; + border-radius: 6px; + padding: 7px 8px; + background: #fff; + color: #1f2933; +} + +.table-wrap { + overflow: auto; + max-height: 560px; + border: 1px solid #dfe6ee; + border-radius: 8px; +} + +.table-wrap--compact { + max-height: 360px; +} + +table { + width: 100%; + min-width: 1180px; + border-collapse: collapse; +} + +th, td { + border-bottom: 1px solid #e7edf3; + padding: 8px 10px; + text-align: left; + vertical-align: middle; +} + +th { + position: sticky; + top: 0; + background: #f5f7fa; + color: #526274; + font-size: 12px; + z-index: 1; +} + +td { + white-space: nowrap; +} + +.url-cell { + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-cell { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.action-cell button { + padding: 5px 8px; +} + +.click-row { + cursor: pointer; +} + +.click-row:hover { + background: #f5f9fd; +} + +.details-block, .network-detail { + margin-top: 12px; +} + +.details-block h3, .network-detail h3 { + margin: 0 0 8px; + font-size: 13px; +} + +.details-block pre { + max-height: 280px; + overflow: auto; + border: 1px solid #dfe6ee; + border-radius: 8px; + background: #f7f9fb; + padding: 10px; + white-space: pre-wrap; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.pill { + display: inline-block; + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.pill--success { + background: #e7f4ed; + color: #236542; +} + +.pill--failed { + background: #fdeaea; + color: #9d3b3b; +} + +@media (max-width: 960px) { + main { + width: min(100vw - 20px, 1480px); + padding-top: 16px; + } + + .app-header { + display: grid; + } + + .header-status { + justify-content: flex-start; + } + + .runtime-item--wide { + grid-column: span 1; + } + + .distribution-grid { + grid-template-columns: 1fr; + } + + .filters { + grid-template-columns: 1fr; + } +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..b7e4478 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,195 @@ +export interface FetchStats { + lifetime_total: number; + lifetime_failed: number; + active: number; + queued: number; +} + +export interface RuntimeInfo { + node_version: string; + platform: string; + arch: string; + cwd: string; + project_root?: string; + host: string; + port: number; + headless: boolean; + profile_dir: string; + proxy_configured: boolean; + proxy_url_masked: string | null; + concurrency: number; + timeout_ms: number; + max_body_mb: number; + history_limit?: number; + network_history_limit?: number; + network_body_capture_bytes?: number; + websocket_heartbeat_ms?: number; + websocket_clients?: number; + dropped_event_count?: number; + stats: FetchStats; + sources?: SourceSessionRuntime[]; + network_summary?: NetworkSummary; +} + +export interface MetricsSummary { + retained_total: number; + retained_success: number; + retained_failed: number; + active: number; + queued: number; + avg_elapsed_ms: number; + p95_elapsed_ms: number; + total_body_size: number; + status_counts: Record; + error_counts: Record; + host_counts: Record; + proxy_configured: boolean; + proxy_url_masked: string | null; +} + +export interface TimeseriesPoint { + time: string; + total: number; + success: number; + failed: number; + avg_elapsed_ms: number; + body_size: number; +} + +export interface RequestRecord { + id: string; + time: string; + kind: "fetch" | "diagnostics"; + fetch_mode: "document"; + cache_mode: "default" | "reload"; + url: string; + host: string | null; + final_url: string | null; + status: number | null; + ok: boolean; + error_code: string | null; + error_message: string | null; + elapsed_ms: number; + body_size: number; + content_type: string | null; + proxy_configured: boolean; + proxy_url_masked: string | null; + source_id: string; +} + +export interface SourceSessionRuntime { + id: string; + state: "starting" | "ready" | "failed" | "closing" | "closed"; + persistent: boolean; + profile_dir: string; + warmup_url: string | null; + current_url: string | null; + created_at: string; + ready_at: string | null; + last_used_at: string | null; + request_count: number; + page_count: number; + active_request_count: number; + total_network_request_count: number; + total_network_failed_count: number; + proxy_configured: boolean; + proxy_url_masked: string | null; + last_error: string | null; +} + +export interface SourcePageInfo { + page_id: string; + url: string; + title: string | null; + main_frame_url: string | null; + created_at: string; + last_activity_at: string; + closed: boolean; +} + +export interface CapturedBody { + size: number; + stored_bytes: number; + truncated: boolean; + encoding: "utf8" | "base64" | null; + text?: string; + base64?: string; +} + +export interface NetworkRecord { + id: string; + source_id: string; + page_id: string | null; + request_id: string; + parent_request_id: string | null; + started_at: string; + finished_at: string | null; + duration_ms: number | null; + url: string; + method: string; + resource_type: string; + navigation: boolean; + frame_url: string | null; + initiator: string | null; + request_headers: Record; + request_body_size: number; + request_post_data: CapturedBody | null; + status: number | null; + status_text: string | null; + protocol: string | null; + response_headers: Record | null; + response_body_size: number; + response_body: CapturedBody | null; + mime_type: string | null; + remote_address: { ip_address: string; port: number } | null; + from_cache: boolean; + service_worker: boolean; + failure_text: string | null; + redirect_from: string | null; + redirect_to: string | null; +} + +export interface NetworkSummary { + retained_total: number; + active: number; + finished: number; + failed: number; + by_method: Record; + by_resource_type: Record; + by_status: Record; + by_host: Record; + by_protocol: Record; + cache_hit_count: number; + total_request_bytes: number; + total_response_bytes: number; + avg_duration_ms: number; + p95_duration_ms: number; +} + +export interface NetworkBodyResponse { + request_post_data: CapturedBody | null; + response_body: CapturedBody | null; +} + +export interface WsMessage { + version: number; + sequence: number; + type: string; + time: string; + data: T; +} + +export interface HelloPayload { + server_time: string; + stats: FetchStats; + summary: MetricsSummary; + network_summary: NetworkSummary; + sources: SourceSessionRuntime[]; + dropped_event_count: number; +} + +export interface HeartbeatPayload { + server_time: string; + stats: FetchStats; + dropped_event_count?: number; +} diff --git a/web/src/ws.ts b/web/src/ws.ts new file mode 100644 index 0000000..c9c930a --- /dev/null +++ b/web/src/ws.ts @@ -0,0 +1,84 @@ +import type { HeartbeatPayload, HelloPayload, NetworkRecord, RequestRecord, SourcePageInfo, SourceSessionRuntime, WsMessage } from "./types"; + +export type WebSocketStatus = "connecting" | "connected" | "disconnected"; + +export interface GatewaySocketCallbacks { + onStatus: (status: WebSocketStatus) => void; + onHello: (payload: HelloPayload) => void; + onRequest: (record: RequestRecord) => void; + onNetwork: (record: NetworkRecord) => void; + onSource: (record: SourceSessionRuntime) => void; + onPage: (record: SourcePageInfo) => void; + onHeartbeat: (payload: HeartbeatPayload) => void; + onError: (message: string) => void; +} + +export interface GatewaySocketHandle { + close: () => void; +} + +export function connectGatewaySocket(callbacks: GatewaySocketCallbacks): GatewaySocketHandle { + let socket: WebSocket | null = null; + let reconnectTimer: number | undefined; + let stopped = false; + + const connect = () => { + callbacks.onStatus("connecting"); + const url = new URL("/v1/ws", window.location.href); + url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + socket = new WebSocket(url); + socket.onopen = () => callbacks.onStatus("connected"); + socket.onmessage = (event) => handleMessage(event.data, callbacks); + socket.onerror = () => callbacks.onError("WebSocket connection error"); + socket.onclose = () => { + callbacks.onStatus("disconnected"); + if (!stopped) { + reconnectTimer = window.setTimeout(connect, 2000); + } + }; + }; + + connect(); + return { + close() { + stopped = true; + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer); + } + socket?.close(); + }, + }; +} + +function handleMessage(raw: string, callbacks: GatewaySocketCallbacks): void { + let message: WsMessage; + try { + message = JSON.parse(raw) as WsMessage; + } catch { + callbacks.onError("Received invalid WebSocket JSON"); + return; + } + if (message.type === "hello") { + callbacks.onHello(message.data as HelloPayload); + return; + } + if (message.type === "request") { + callbacks.onRequest(message.data as RequestRecord); + return; + } + if (message.type.startsWith("network.")) { + callbacks.onNetwork(message.data as NetworkRecord); + return; + } + if (message.type.startsWith("source.")) { + callbacks.onSource(message.data as SourceSessionRuntime); + return; + } + if (message.type.startsWith("page.")) { + callbacks.onPage(message.data as SourcePageInfo); + return; + } + if (message.type === "heartbeat") { + callbacks.onHeartbeat(message.data as HeartbeatPayload); + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..0cc6814 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..f03322f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,27 @@ +import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const webRoot = fileURLToPath(new URL(".", import.meta.url)); +const backend = "http://127.0.0.1:9230"; + +export default defineConfig({ + root: webRoot, + base: "/console/", + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + host: "127.0.0.1", + port: 5173, + proxy: { + "/health": backend, + "/v1": { + target: backend, + ws: true, + }, + }, + }, +});