From 47ca65797200933fc696f59fc0f8f1acd9657cea Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 8 Sep 2026 02:33:29 +0900 Subject: [PATCH] sitemap, web: implement hydrant stream sitemap worker and web proxy Signed-off-by: dawn --- sitemap/package.json | 19 + sitemap/pnpm-lock.yaml | 1861 ++++++++++++++++++++++++++++++++++ sitemap/pnpm-workspace.yaml | 6 + sitemap/src/hydrant.ts | 89 ++ sitemap/src/index.ts | 459 +++++++++ sitemap/src/sitemap.ts | 147 +++ sitemap/src/types.ts | 67 ++ sitemap/src/xml.ts | 81 ++ sitemap/test/sitemap.test.ts | 253 +++++ sitemap/test/sync.test.ts | 296 ++++++ sitemap/tsconfig.json | 17 + sitemap/wrangler.dev.jsonc | 37 + sitemap/wrangler.jsonc | 35 + web/src/app.d.ts | 1 + web/src/hooks.server.ts | 10 + web/wrangler.dev.jsonc | 12 +- 16 files changed, 3388 insertions(+), 2 deletions(-) create mode 100644 sitemap/package.json create mode 100644 sitemap/pnpm-lock.yaml create mode 100644 sitemap/pnpm-workspace.yaml create mode 100644 sitemap/src/hydrant.ts create mode 100644 sitemap/src/index.ts create mode 100644 sitemap/src/sitemap.ts create mode 100644 sitemap/src/types.ts create mode 100644 sitemap/src/xml.ts create mode 100644 sitemap/test/sitemap.test.ts create mode 100644 sitemap/test/sync.test.ts create mode 100644 sitemap/tsconfig.json create mode 100644 sitemap/wrangler.dev.jsonc create mode 100644 sitemap/wrangler.jsonc diff --git a/sitemap/package.json b/sitemap/package.json new file mode 100644 index 000000000..70565a0c0 --- /dev/null +++ b/sitemap/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tangled/sitemap-worker", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "deploy:dev": "wrangler deploy --config wrangler.dev.jsonc" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "typescript": "^5.9.3", + "vitest": "~3.0.7", + "wrangler": "^4.75.0" + } +} diff --git a/sitemap/pnpm-lock.yaml b/sitemap/pnpm-lock.yaml new file mode 100644 index 000000000..d87fa5dc0 --- /dev/null +++ b/sitemap/pnpm-lock.yaml @@ -0,0 +1,1861 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260317.1 + version: 4.20260702.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ~3.0.7 + version: 3.0.9(supports-color@10.2.2) + wrangler: + specifier: ^4.75.0 + version: 4.129.0(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260903.1': + resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260903.1': + resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260903.1': + resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260903.1': + resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260903.1': + resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@3.0.9': + resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} + + '@vitest/mocker@3.0.9': + resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.0.9': + resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.0.9': + resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==} + + '@vitest/snapshot@3.0.9': + resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==} + + '@vitest/spy@3.0.9': + resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} + + '@vitest/utils@3.0.9': + resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + miniflare@5.20260903.0-alpha: + resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} + engines: {node: '>=22.0.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + vite-node@3.0.9: + resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.0.9: + resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.0.9 + '@vitest/ui': 3.0.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260903.1: + resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.129.0: + resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260903.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260903.1 + + '@cloudflare/workerd-darwin-64@1.20260903.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260903.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260903.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260903.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260903.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@3.0.9': + dependencies: + '@vitest/spy': 3.0.9 + '@vitest/utils': 3.0.9 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.0.9(vite@6.4.3)': + dependencies: + '@vitest/spy': 3.0.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3 + + '@vitest/pretty-format@3.0.9': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.0.9': + dependencies: + '@vitest/utils': 3.0.9 + pathe: 2.0.3 + + '@vitest/snapshot@3.0.9': + dependencies: + '@vitest/pretty-format': 3.0.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.0.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.0.9': + dependencies: + '@vitest/pretty-format': 3.0.9 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + blake3-wasm@2.1.5: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + cookie@1.1.1: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + 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 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + miniflare@5.20260903.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260903.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite-node@3.0.9(supports-color@10.2.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.0.9(supports-color@10.2.2): + dependencies: + '@vitest/expect': 3.0.9 + '@vitest/mocker': 3.0.9(vite@6.4.3) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.0.9 + '@vitest/snapshot': 3.0.9 + '@vitest/spy': 3.0.9 + '@vitest/utils': 3.0.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3 + vite-node: 3.0.9(supports-color@10.2.2) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260903.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260903.1 + '@cloudflare/workerd-darwin-arm64': 1.20260903.1 + '@cloudflare/workerd-linux-64': 1.20260903.1 + '@cloudflare/workerd-linux-arm64': 1.20260903.1 + '@cloudflare/workerd-windows-64': 1.20260903.1 + + wrangler@4.129.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260903.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260903.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/sitemap/pnpm-workspace.yaml b/sitemap/pnpm-workspace.yaml new file mode 100644 index 000000000..c1ffadc04 --- /dev/null +++ b/sitemap/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + esbuild: true + workerd: true +onlyBuiltDependencies: + - esbuild + - workerd diff --git a/sitemap/src/hydrant.ts b/sitemap/src/hydrant.ts new file mode 100644 index 000000000..b2d1894f4 --- /dev/null +++ b/sitemap/src/hydrant.ts @@ -0,0 +1,89 @@ +import type { HydrantEvent, HydrantRecordValue, UrlEntry } from './types'; +import { sanitizeLastmod } from './xml'; + +export interface ExtractedAction { + type: 'upsert' | 'delete'; + url: string; + entry?: UrlEntry; +} + +interface CollectionSpec { + buildPath: (owner: string, rkey: string, record?: HydrantRecordValue) => string; + changefreq: UrlEntry['changefreq']; + priority: number; +} + +export const COLLECTION_SPECS: Record = { + 'sh.tangled.repo': { + buildPath: (owner, rkey, record) => `${owner}/${(record?.name as string) || rkey}`, + changefreq: 'daily', + priority: 0.8, + }, + 'sh.tangled.actor.profile': { + buildPath: (owner) => owner, + changefreq: 'daily', + priority: 0.8, + }, + 'sh.tangled.string': { + buildPath: (owner, rkey) => `${owner}/strings/${rkey}`, + changefreq: 'weekly', + priority: 0.6, + }, +}; + +export function resolveIdentity( + did: string, + explicitHandle?: string, + identityMap?: Map, +): string { + if (explicitHandle && explicitHandle.trim().length > 0) { + if (identityMap) identityMap.set(did, explicitHandle); + return explicitHandle; + } + return identityMap?.get(did) || did; +} + +export function extractUrlAction( + event: HydrantEvent, + baseUrl: string, + identityMap: Map, + now = new Date(), +): ExtractedAction | null { + if (event.type === 'identity' && event.identity) { + if (event.identity.handle) { + identityMap.set(event.identity.did, event.identity.handle); + } + return null; + } + + if (event.type !== 'record' || !event.record) { + return null; + } + + const { collection, rkey, action, did, handle, record } = event.record; + const spec = COLLECTION_SPECS[collection]; + if (!spec) { + return null; + } + + const owner = resolveIdentity(did, handle, identityMap); + const normalizedBase = baseUrl.replace(/\/+$/, ''); + const path = spec.buildPath(owner, rkey, record); + const url = `${normalizedBase}/${path}`; + + if (action === 'delete') { + return { type: 'delete', url }; + } + + const lastmod = sanitizeLastmod(record?.createdAt as string | undefined, now); + return { + type: 'upsert', + url, + entry: { + loc: url, + lastmod, + changefreq: spec.changefreq, + priority: spec.priority, + }, + }; +} diff --git a/sitemap/src/index.ts b/sitemap/src/index.ts new file mode 100644 index 000000000..27b44e21a --- /dev/null +++ b/sitemap/src/index.ts @@ -0,0 +1,459 @@ +import type { Env, ShardInfo, UrlEntry, WorkerState } from './types'; +import { extractUrlAction } from './hydrant'; +import { + flushShardToR2, + formatShardFilename, + getStaticUrls, + loadShardEntries, + splitEntriesByLimits, + updateSitemapIndex, +} from './sitemap'; +import { + buildUrlsetXml, + measureXmlBytes, + PROTOCOL_MAX_BYTES, + PROTOCOL_MAX_URLS, + sanitizeLastmod, +} from './xml'; + +const KEY_WORKER_STATE = 'worker_state'; +const KEY_IDENTITY_CACHE = 'identity_cache'; + +export function parseConfigLimits(env: Env): { maxUrls: number; maxBytes: number } { + const parsedUrls = parseInt(env.SHARD_MAX_URLS || '', 10); + const parsedBytes = parseInt(env.SHARD_MAX_BYTES || '', 10); + + const maxUrls = !isNaN(parsedUrls) && parsedUrls > 0 + ? Math.min(parsedUrls, PROTOCOL_MAX_URLS) + : PROTOCOL_MAX_URLS; + + const maxBytes = !isNaN(parsedBytes) && parsedBytes > 0 + ? Math.min(parsedBytes, PROTOCOL_MAX_BYTES) + : PROTOCOL_MAX_BYTES; + + return { maxUrls, maxBytes }; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const pathname = url.pathname; + + if (pathname === '/sitemap.xml') { + const obj = await env.SITEMAP_BUCKET.get('sitemap.xml'); + if (!obj) { + return new Response('Sitemap index not found. Has sync run yet?', { status: 404 }); + } + + return new Response(obj.body, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + }, + }); + } + + if (pathname.startsWith('/sitemaps/')) { + const filename = pathname.replace(/^\/sitemaps\//, ''); + const obj = await env.SITEMAP_BUCKET.get(`sitemaps/${filename}`); + if (!obj) { + return new Response('Sitemap shard not found', { status: 404 }); + } + + return new Response(obj.body, { + headers: { + 'Content-Type': 'application/x-gzip', + 'Content-Encoding': 'gzip', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', + }, + }); + } + + + + return new Response('Not Found', { status: 404 }); + }, + + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + console.log(`[sitemap] Cron trigger fired at ${new Date().toISOString()} (cron: ${event.cron})`); + ctx.waitUntil((async () => { + try { + const res = await syncFromHydrant(env); + console.log(`[sitemap] Sync finished: success=${res.success}, events=${res.syncedEvents}, mutated=${res.mutatedUrls}, totalUrls=${res.totalUrls}, cursor=${res.newCursor}${res.error ? `, error=${res.error}` : ''}`); + } catch (err) { + console.error('[sitemap] Unhandled error during scheduled sync:', err); + } + })()); + }, +}; + +export async function loadWorkerState(kv: KVNamespace): Promise { + const raw = await kv.get(KEY_WORKER_STATE); + if (!raw) { + return { + cursor: '0', + lastSyncAt: 'never', + totalUrls: 0, + shards: [], + urlLocator: {}, + }; + } + + try { + const parsed = JSON.parse(raw); + return { + cursor: typeof parsed.cursor === 'string' ? parsed.cursor : '0', + lastSyncAt: typeof parsed.lastSyncAt === 'string' ? parsed.lastSyncAt : 'never', + totalUrls: typeof parsed.totalUrls === 'number' ? parsed.totalUrls : 0, + shards: Array.isArray(parsed.shards) ? parsed.shards : [], + urlLocator: typeof parsed.urlLocator === 'object' && parsed.urlLocator !== null ? parsed.urlLocator : {}, + }; + } catch { + return { + cursor: '0', + lastSyncAt: 'never', + totalUrls: 0, + shards: [], + urlLocator: {}, + }; + } +} + +export async function loadIdentityMap(kv: KVNamespace): Promise> { + const raw = await kv.get(KEY_IDENTITY_CACHE); + if (!raw) return new Map(); + try { + const record = JSON.parse(raw); + return new Map(Object.entries(record)); + } catch { + return new Map(); + } +} + +export async function saveIdentityMap(kv: KVNamespace, map: Map): Promise { + const obj = Object.fromEntries(map.entries()); + await kv.put(KEY_IDENTITY_CACHE, JSON.stringify(obj)); +} + +export async function syncFromHydrant(env: Env): Promise<{ + success: boolean; + syncedEvents: number; + mutatedUrls: number; + newCursor: string; + totalUrls: number; + error?: string; +}> { + const baseUrl = env.BASE_URL || 'https://tangled.org'; + const { maxUrls, maxBytes } = parseConfigLimits(env); + const now = new Date(); + + const state = await loadWorkerState(env.SITEMAP_KV); + const identityMap = await loadIdentityMap(env.SITEMAP_KV); + + // Initialize shard 1 if needed + if (state.shards.length === 0) { + state.shards.push({ + index: 1, + filename: formatShardFilename(1), + count: 0, + uncompressedBytes: 0, + sealed: false, + lastmod: sanitizeLastmod(undefined, now), + }); + } + + let activeShard = state.shards[state.shards.length - 1]; + if (activeShard.sealed) { + const nextIndex = activeShard.index + 1; + activeShard = { + index: nextIndex, + filename: formatShardFilename(nextIndex), + count: 0, + uncompressedBytes: 0, + sealed: false, + lastmod: sanitizeLastmod(undefined, now), + }; + state.shards.push(activeShard); + } + + // Load active shard entries + const activeEntries = await loadShardEntries(env.SITEMAP_BUCKET, activeShard.filename); + + // Seed static URLs on initial empty setup + let mutated = false; + if (activeShard.index === 1 && activeEntries.size === 0) { + const staticUrls = getStaticUrls(baseUrl, now); + for (const entry of staticUrls) { + activeEntries.set(entry.loc, entry); + state.urlLocator[entry.loc] = activeShard.index; + } + mutated = true; + } + + // Fetch events with bounded pagination + const fetchResult = await fetchHydrantStreamPages(env, state.cursor); + if (!fetchResult.ok) { + return { + success: false, + syncedEvents: 0, + mutatedUrls: 0, + newCursor: state.cursor, + totalUrls: state.totalUrls, + error: fetchResult.error, + }; + } + + const hydrantEvents = fetchResult.events; + let highestCursor = state.cursor; + let eventCount = 0; + let mutationCount = 0; + + // Track historical shards modified by in-place updates or deletes: shardIndex -> { upserts, deletes } + const historicalModifications = new Map< + number, + { upserts: Map; deletes: Set } + >(); + + function getHistoricalMod(shardIndex: number) { + let mod = historicalModifications.get(shardIndex); + if (!mod) { + mod = { upserts: new Map(), deletes: new Set() }; + historicalModifications.set(shardIndex, mod); + } + return mod; + } + + for (const evt of hydrantEvents) { + highestCursor = String(Math.max(Number(highestCursor), evt.id)); + eventCount++; + + const action = extractUrlAction(evt, baseUrl, identityMap, now); + if (!action) continue; + + if (action.type === 'delete') { + const targetShardIndex = state.urlLocator[action.url]; + if (!targetShardIndex) continue; + + if (targetShardIndex === activeShard.index) { + if (activeEntries.delete(action.url)) { + delete state.urlLocator[action.url]; + mutated = true; + mutationCount++; + } + } else { + const mod = getHistoricalMod(targetShardIndex); + mod.deletes.add(action.url); + mod.upserts.delete(action.url); + delete state.urlLocator[action.url]; + mutated = true; + mutationCount++; + } + } else if (action.type === 'upsert' && action.entry) { + const existingShardIndex = state.urlLocator[action.url]; + + if (!existingShardIndex || existingShardIndex === activeShard.index) { + // New URL or already in active shard + activeEntries.set(action.url, action.entry); + state.urlLocator[action.url] = activeShard.index; + mutated = true; + mutationCount++; + } else { + // In-place update in historical shard! (finding #4) + const mod = getHistoricalMod(existingShardIndex); + mod.upserts.set(action.url, action.entry); + mod.deletes.delete(action.url); + mutated = true; + mutationCount++; + } + } + } + + // Apply in-place modifications to historical shards + for (const [shardIndex, mod] of historicalModifications.entries()) { + const shardMeta = state.shards.find((s) => s.index === shardIndex); + if (!shardMeta) continue; + + const shardEntries = await loadShardEntries(env.SITEMAP_BUCKET, shardMeta.filename); + for (const url of mod.deletes) { + shardEntries.delete(url); + } + for (const [url, entry] of mod.upserts) { + shardEntries.set(url, entry); + } + + const updatedBytes = await flushShardToR2( + env.SITEMAP_BUCKET, + shardMeta.filename, + Array.from(shardEntries.values()), + ); + shardMeta.count = shardEntries.size; + shardMeta.uncompressedBytes = updatedBytes; + shardMeta.lastmod = sanitizeLastmod(undefined, now); + } + + // Enforce both maxUrls and maxBytes rollover limits (finding #1) + let entriesArray = Array.from(activeEntries.values()); + while (true) { + const currentBytes = measureXmlBytes(buildUrlsetXml(entriesArray)); + if (entriesArray.length <= maxUrls && currentBytes <= maxBytes) { + break; + } + + const { chunk, remainder } = splitEntriesByLimits(entriesArray, maxUrls, maxBytes); + if (chunk.length === 0 || remainder.length === 0) { + break; + } + + activeShard.count = chunk.length; + activeShard.sealed = true; + activeShard.lastmod = sanitizeLastmod(undefined, now); + activeShard.uncompressedBytes = await flushShardToR2( + env.SITEMAP_BUCKET, + activeShard.filename, + chunk, + ); + + for (const entry of chunk) { + state.urlLocator[entry.loc] = activeShard.index; + } + + // Open next shard + const nextIndex = activeShard.index + 1; + activeShard = { + index: nextIndex, + filename: formatShardFilename(nextIndex), + count: remainder.length, + uncompressedBytes: 0, + sealed: false, + lastmod: sanitizeLastmod(undefined, now), + }; + state.shards.push(activeShard); + entriesArray = remainder; + mutated = true; + } + + // Only flush active shard and index if changes actually occurred + if (mutated) { + activeShard.count = entriesArray.length; + activeShard.lastmod = sanitizeLastmod(undefined, now); + activeShard.uncompressedBytes = await flushShardToR2( + env.SITEMAP_BUCKET, + activeShard.filename, + entriesArray, + ); + + for (const entry of entriesArray) { + state.urlLocator[entry.loc] = activeShard.index; + } + + await updateSitemapIndex(env.SITEMAP_BUCKET, state.shards, baseUrl); + } + + // Compute total URL count + state.totalUrls = state.shards.reduce((acc, s) => acc + s.count, 0); + state.cursor = highestCursor; + + // Commit ordering: identity map saved FIRST, then worker state is canonical commit marker (finding #3) + if (mutated || eventCount > 0) { + state.lastSyncAt = now.toISOString(); + await saveIdentityMap(env.SITEMAP_KV, identityMap); + await env.SITEMAP_KV.put(KEY_WORKER_STATE, JSON.stringify(state)); + } + + return { + success: true, + syncedEvents: eventCount, + mutatedUrls: mutationCount, + newCursor: state.cursor, + totalUrls: state.totalUrls, + }; +} + +export async function fetchHydrantStreamPages( + env: Env, + startCursor: string, + maxEvents = 10000, + idleTimeoutMs = 3000, +): Promise<{ ok: boolean; events: any[]; error?: string }> { + const client = env.HYDRANT ?? { fetch }; + const endpoint = env.HYDRANT + ? `http://hydrant.internal/stream?cursor=${startCursor}` + : `${(env.HYDRANT_URL || 'https://api.tangled.org').replace(/\/+$/, '')}/stream?cursor=${startCursor}`; + + try { + const res = await client.fetch(endpoint, { + headers: { Upgrade: 'websocket' }, + }); + + const ws = (res as any).webSocket; + if (!ws) { + // Fallback for mocks / non-websocket responses in tests + if (!res.ok) { + const errText = await res.text(); + return { ok: false, events: [], error: `Hydrant returned HTTP ${res.status}: ${errText}` }; + } + const body = await res.json(); + const events: any[] = Array.isArray(body) ? body : (body as any)?.events ?? []; + return { ok: true, events }; + } + + ws.accept(); + + return new Promise((resolve) => { + const events: any[] = []; + let timer: ReturnType | null = null; + + function resetIdleTimer() { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + cleanup(); + resolve({ ok: true, events }); + }, idleTimeoutMs); + } + + function cleanup() { + if (timer) clearTimeout(timer); + try { + ws.close(1000, 'batch finished'); + } catch {} + } + + ws.addEventListener('message', (event: any) => { + try { + const raw = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + if (parsed.type === 'error') { + cleanup(); + resolve({ ok: false, events, error: parsed.error || 'Hydrant stream error' }); + return; + } + events.push(parsed); + if (events.length >= maxEvents) { + cleanup(); + resolve({ ok: true, events }); + return; + } + } + resetIdleTimer(); + } catch (err: any) { + console.error('[sitemap] Error parsing ws frame:', err); + } + }); + + ws.addEventListener('error', (err: any) => { + cleanup(); + resolve({ ok: events.length > 0, events, error: err?.message || 'WebSocket error' }); + }); + + ws.addEventListener('close', () => { + if (timer) clearTimeout(timer); + resolve({ ok: true, events }); + }); + + resetIdleTimer(); + }); + } catch (err: any) { + return { ok: false, events: [], error: err?.message || 'Network error' }; + } +} diff --git a/sitemap/src/sitemap.ts b/sitemap/src/sitemap.ts new file mode 100644 index 000000000..f1ceddbde --- /dev/null +++ b/sitemap/src/sitemap.ts @@ -0,0 +1,147 @@ +import type { ShardInfo, UrlEntry } from './types'; +import { + buildSitemapIndexXml, + buildUrlsetXml, + gzipCompress, + gzipDecompress, + measureXmlBytes, + sanitizeLastmod, + unescapeXml, +} from './xml'; + +export function formatShardFilename(index: number): string { + return `sitemap-${String(index).padStart(4, '0')}.xml.gz`; +} + +export function parseUrlsetXml(xml: string): UrlEntry[] { + const entries: UrlEntry[] = []; + const urlRegex = /([\s\S]*?)<\/url>/g; + const locRegex = /(.*?)<\/loc>/; + const lastmodRegex = /(.*?)<\/lastmod>/; + const changefreqRegex = /(.*?)<\/changefreq>/; + const priorityRegex = /(.*?)<\/priority>/; + + let match: RegExpExecArray | null; + while ((match = urlRegex.exec(xml)) !== null) { + const block = match[1]; + const locMatch = locRegex.exec(block); + if (!locMatch) continue; + + const loc = unescapeXml(locMatch[1]); + const rawLastmod = lastmodRegex.exec(block)?.[1]; + const lastmod = rawLastmod ? unescapeXml(rawLastmod) : undefined; + const changefreq = changefreqRegex.exec(block)?.[1] as UrlEntry['changefreq']; + const priorityStr = priorityRegex.exec(block)?.[1]; + const priority = priorityStr ? parseFloat(priorityStr) : undefined; + + entries.push({ loc, lastmod, changefreq, priority }); + } + + return entries; +} + +export function getStaticUrls(baseUrl: string, now = new Date()): UrlEntry[] { + const normalized = baseUrl.replace(/\/+$/, ''); + const today = sanitizeLastmod(undefined, now); + + return [ + { + loc: `${normalized}/`, + lastmod: today, + changefreq: 'daily', + priority: 1.0, + }, + { + loc: `${normalized}/about`, + lastmod: today, + changefreq: 'monthly', + priority: 0.8, + }, + ]; +} + +export async function loadShardEntries( + bucket: R2Bucket, + filename: string, +): Promise> { + const existing = await bucket.get(`sitemaps/${filename}`); + if (!existing) { + return new Map(); + } + + const arrayBuffer = await existing.arrayBuffer(); + const xml = await gzipDecompress(arrayBuffer); + const parsed = parseUrlsetXml(xml); + + const map = new Map(); + for (const entry of parsed) { + map.set(entry.loc, entry); + } + return map; +} + +export async function flushShardToR2( + bucket: R2Bucket, + filename: string, + entries: UrlEntry[], +): Promise { + const xml = buildUrlsetXml(entries); + const byteLength = measureXmlBytes(xml); + const compressed = await gzipCompress(xml); + + await bucket.put(`sitemaps/${filename}`, compressed, { + httpMetadata: { + contentType: 'application/x-gzip', + contentEncoding: 'gzip', + cacheControl: 'public, max-age=3600, stale-while-revalidate=86400', + }, + }); + + return byteLength; +} + +export async function updateSitemapIndex( + bucket: R2Bucket, + shards: ShardInfo[], + baseUrl: string, +): Promise { + const indexXml = buildSitemapIndexXml(shards, baseUrl); + await bucket.put('sitemap.xml', indexXml, { + httpMetadata: { + contentType: 'application/xml', + cacheControl: 'public, max-age=86400, s-maxage=86400', + }, + }); +} + +/** + * Splits an array of entries into a chunk that satisfies both maxUrls AND maxBytes, + * and a remainder array. + */ +export function splitEntriesByLimits( + entries: UrlEntry[], + maxUrls: number, + maxBytes: number, +): { chunk: UrlEntry[]; remainder: UrlEntry[] } { + if (entries.length === 0) { + return { chunk: [], remainder: [] }; + } + + // Binary search or linear take up to bounds + let count = Math.min(entries.length, maxUrls); + let xml = buildUrlsetXml(entries.slice(0, count)); + let bytes = measureXmlBytes(xml); + + while (bytes > maxBytes && count > 1) { + // Step down proportionally + const ratio = maxBytes / bytes; + count = Math.max(1, Math.min(count - 1, Math.floor(count * ratio))); + xml = buildUrlsetXml(entries.slice(0, count)); + bytes = measureXmlBytes(xml); + } + + return { + chunk: entries.slice(0, count), + remainder: entries.slice(count), + }; +} diff --git a/sitemap/src/types.ts b/sitemap/src/types.ts new file mode 100644 index 000000000..f34abbf6a --- /dev/null +++ b/sitemap/src/types.ts @@ -0,0 +1,67 @@ +export interface Env { + SITEMAP_KV: KVNamespace; + SITEMAP_BUCKET: R2Bucket; + HYDRANT?: Fetcher; + BASE_URL?: string; + HYDRANT_URL?: string; + SHARD_MAX_URLS?: string; + SHARD_MAX_BYTES?: string; +} + +export type DbAction = 'create' | 'update' | 'delete'; + +export interface HydrantRecordValue { + name?: string; + createdAt?: string; + handle?: string; + description?: string; + [key: string]: unknown; +} + +export interface HydrantRecordPayload { + live?: boolean; + did: string; + handle?: string; + rev?: string; + collection: string; + rkey: string; + action: DbAction; + record?: HydrantRecordValue; + cid?: string; +} + +export interface HydrantIdentityPayload { + did: string; + handle?: string; +} + +export interface HydrantEvent { + id: number; + type: 'record' | 'identity' | 'account'; + record?: HydrantRecordPayload; + identity?: HydrantIdentityPayload; +} + +export interface UrlEntry { + loc: string; + lastmod?: string; + changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority?: number; +} + +export interface ShardInfo { + index: number; + filename: string; + count: number; + uncompressedBytes: number; + sealed: boolean; + lastmod: string; +} + +export interface WorkerState { + cursor: string; + lastSyncAt: string; + totalUrls: number; + shards: ShardInfo[]; + urlLocator: Record; +} diff --git a/sitemap/src/xml.ts b/sitemap/src/xml.ts new file mode 100644 index 000000000..e215e4560 --- /dev/null +++ b/sitemap/src/xml.ts @@ -0,0 +1,81 @@ +import type { ShardInfo, UrlEntry } from './types'; + +const XML_HEADER = ''; +const SITEMAP_SCHEMA = 'http://www.sitemaps.org/schemas/sitemap/0.9'; +const TANGLED_EPOCH = new Date('2025-01-01T00:00:00Z'); + +export const PROTOCOL_MAX_URLS = 50000; +export const PROTOCOL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB uncompressed + +export function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function unescapeXml(str: string): string { + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +export function sanitizeLastmod(rawDate?: string, referenceTime = new Date()): string { + if (!rawDate) { + return referenceTime.toISOString().split('T')[0]; + } + + const parsed = new Date(rawDate); + if (isNaN(parsed.getTime()) || parsed > referenceTime || parsed < TANGLED_EPOCH) { + return referenceTime.toISOString().split('T')[0]; + } + + return parsed.toISOString().split('T')[0]; +} + +export function measureXmlBytes(xml: string): number { + return new TextEncoder().encode(xml).byteLength; +} + +export function buildUrlsetXml(urls: UrlEntry[]): string { + const body = urls + .map((u) => { + const parts = [` ${escapeXml(u.loc)}`]; + if (u.lastmod) parts.push(` ${escapeXml(u.lastmod)}`); + if (u.changefreq) parts.push(` ${u.changefreq}`); + if (u.priority !== undefined) parts.push(` ${u.priority.toFixed(1)}`); + return ` \n${parts.join('\n')}\n `; + }) + .join('\n'); + + return `${XML_HEADER}\n\n${body}\n\n`; +} + +export function buildSitemapIndexXml(shards: ShardInfo[], baseUrl: string): string { + const body = shards + .map((shard) => { + const loc = `${baseUrl.replace(/\/+$/, '')}/sitemaps/${shard.filename}`; + return ` \n ${escapeXml(loc)}\n ${escapeXml(shard.lastmod)}\n `; + }) + .join('\n'); + + return `${XML_HEADER}\n\n${body}\n\n`; +} + +export async function gzipCompress(text: string): Promise { + const blob = new Blob([text]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +export async function gzipDecompress(data: ArrayBuffer | Uint8Array): Promise { + const blob = new Blob([data as any]); + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + return await new Response(stream).text(); +} diff --git a/sitemap/test/sitemap.test.ts b/sitemap/test/sitemap.test.ts new file mode 100644 index 000000000..abd45241a --- /dev/null +++ b/sitemap/test/sitemap.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; +import { extractUrlAction, resolveIdentity } from '../src/hydrant'; +import { + formatShardFilename, + getStaticUrls, + parseUrlsetXml, +} from '../src/sitemap'; +import type { HydrantEvent, ShardInfo, UrlEntry } from '../src/types'; +import { + buildSitemapIndexXml, + buildUrlsetXml, + escapeXml, + gzipCompress, + gzipDecompress, + measureXmlBytes, + sanitizeLastmod, + unescapeXml, +} from '../src/xml'; + +describe('XML & Compression', () => { + it('escapes and unescapes XML entities safely', () => { + const raw = ''; + const escaped = escapeXml(raw); + expect(escaped).toBe('<script>alert("xss" & 'test')</script>'); + expect(unescapeXml(escaped)).toBe(raw); + }); + + it('clamps future or ancient dates in sanitizeLastmod', () => { + const now = new Date('2026-09-08T12:00:00Z'); + expect(sanitizeLastmod('2099-01-01T00:00:00Z', now)).toBe('2026-09-08'); + expect(sanitizeLastmod('1999-01-01T00:00:00Z', now)).toBe('2026-09-08'); + expect(sanitizeLastmod('invalid-date', now)).toBe('2026-09-08'); + expect(sanitizeLastmod('2026-05-10T15:30:00Z', now)).toBe('2026-05-10'); + }); + + it('builds standard urlset XML and roundtrips with parseUrlsetXml including entities', () => { + const urls: UrlEntry[] = [ + { loc: 'https://tangled.org/', lastmod: '2026-09-08', changefreq: 'daily', priority: 1.0 }, + { loc: 'https://tangled.org/about', lastmod: '2026-09-08', changefreq: 'monthly', priority: 0.8 }, + { loc: 'https://tangled.org/alice/repo?foo=1&bar=2', lastmod: '2026-08-01', priority: 0.8 }, + ]; + + const xml = buildUrlsetXml(urls); + expect(xml).toContain(''); + expect(xml).toContain('https://tangled.org/alice/repo?foo=1&bar=2'); + + const parsed = parseUrlsetXml(xml); + expect(parsed).toHaveLength(3); + expect(parsed[0].loc).toBe('https://tangled.org/'); + expect(parsed[2].loc).toBe('https://tangled.org/alice/repo?foo=1&bar=2'); + }); + + it('measures uncompressed XML byte length accurately', () => { + const xml = 'hello world 🚀'; + expect(measureXmlBytes(xml)).toBe(new TextEncoder().encode(xml).byteLength); + }); + + it('builds valid sitemapindex XML referencing shards', () => { + const shards: ShardInfo[] = [ + { index: 1, filename: 'sitemap-0001.xml.gz', count: 50000, uncompressedBytes: 4500000, sealed: true, lastmod: '2026-08-01' }, + { index: 2, filename: 'sitemap-0002.xml.gz', count: 1200, uncompressedBytes: 120000, sealed: false, lastmod: '2026-09-08' }, + ]; + + const xml = buildSitemapIndexXml(shards, 'https://tangled.org'); + expect(xml).toContain(''); + expect(xml).toContain('https://tangled.org/sitemaps/sitemap-0001.xml.gz'); + expect(xml).toContain('https://tangled.org/sitemaps/sitemap-0002.xml.gz'); + }); + + it('compresses and decompresses XML using native streams', async () => { + const original = 'https://tangled.org/test'; + const compressed = await gzipCompress(original); + expect(compressed.length).toBeGreaterThan(0); + + const decompressed = await gzipDecompress(compressed); + expect(decompressed).toBe(original); + }); +}); + +describe('Hydrant Event Processing', () => { + const baseUrl = 'https://tangled.org'; + const now = new Date('2026-09-08T12:00:00Z'); + + it('resolves handle via identity event', () => { + const identityMap = new Map(); + const identityEvt: HydrantEvent = { + id: 100, + type: 'identity', + identity: { + did: 'did:plc:alice123', + handle: 'alice.tangled.org', + }, + }; + + const action = extractUrlAction(identityEvt, baseUrl, identityMap, now); + expect(action).toBeNull(); + expect(identityMap.get('did:plc:alice123')).toBe('alice.tangled.org'); + expect(resolveIdentity('did:plc:alice123', undefined, identityMap)).toBe('alice.tangled.org'); + expect(resolveIdentity('did:plc:unknown', undefined, identityMap)).toBe('did:plc:unknown'); + }); + + it('prefers explicit handle on record payload if present', () => { + const identityMap = new Map(); + const event: HydrantEvent = { + id: 101, + type: 'record', + record: { + did: 'did:plc:bob456', + handle: 'bob-explicit', + collection: 'sh.tangled.repo', + rkey: 'my-project', + action: 'create', + record: { createdAt: '2026-08-01T00:00:00Z' }, + }, + }; + + const action = extractUrlAction(event, baseUrl, identityMap, now); + expect(action?.url).toBe('https://tangled.org/bob-explicit/my-project'); + expect(identityMap.get('did:plc:bob456')).toBe('bob-explicit'); + }); + + it('extracts repo creation event with resolved handle', () => { + const identityMap = new Map([['did:plc:alice123', 'alice']]); + const event: HydrantEvent = { + id: 101, + type: 'record', + record: { + did: 'did:plc:alice123', + collection: 'sh.tangled.repo', + rkey: 'cool-tool', + action: 'create', + record: { + name: 'cool-tool', + createdAt: '2026-07-15T00:00:00Z', + }, + }, + }; + + const action = extractUrlAction(event, baseUrl, identityMap, now); + expect(action).not.toBeNull(); + expect(action?.type).toBe('upsert'); + expect(action?.url).toBe('https://tangled.org/alice/cool-tool'); + expect(action?.entry?.lastmod).toBe('2026-07-15'); + expect(action?.entry?.priority).toBe(0.8); + }); + + it('extracts profile creation event', () => { + const identityMap = new Map([['did:plc:bob456', 'bob']]); + const event: HydrantEvent = { + id: 102, + type: 'record', + record: { + did: 'did:plc:bob456', + collection: 'sh.tangled.actor.profile', + rkey: 'self', + action: 'create', + record: { + createdAt: '2026-06-01T00:00:00Z', + }, + }, + }; + + const action = extractUrlAction(event, baseUrl, identityMap, now); + expect(action?.type).toBe('upsert'); + expect(action?.url).toBe('https://tangled.org/bob'); + }); + + it('extracts string creation event', () => { + const identityMap = new Map([['did:plc:alice123', 'alice']]); + const event: HydrantEvent = { + id: 103, + type: 'record', + record: { + did: 'did:plc:alice123', + collection: 'sh.tangled.string', + rkey: 'tid-3xyz', + action: 'create', + record: { + createdAt: '2026-08-01T00:00:00Z', + }, + }, + }; + + const action = extractUrlAction(event, baseUrl, identityMap, now); + expect(action?.type).toBe('upsert'); + expect(action?.url).toBe('https://tangled.org/alice/strings/tid-3xyz'); + expect(action?.entry?.changefreq).toBe('weekly'); + }); + + it('emits delete action when a repo is deleted', () => { + const identityMap = new Map([['did:plc:alice123', 'alice']]); + const event: HydrantEvent = { + id: 104, + type: 'record', + record: { + did: 'did:plc:alice123', + collection: 'sh.tangled.repo', + rkey: 'deleted-repo', + action: 'delete', + }, + }; + + const action = extractUrlAction(event, baseUrl, identityMap, now); + expect(action?.type).toBe('delete'); + expect(action?.url).toBe('https://tangled.org/alice/deleted-repo'); + }); + + it('ignores pull requests, diffs, comments, and unknown collections', () => { + const identityMap = new Map(); + const pullEvent: HydrantEvent = { + id: 105, + type: 'record', + record: { + did: 'did:plc:alice123', + collection: 'sh.tangled.repo.pull', + rkey: 'pull-1', + action: 'create', + }, + }; + + expect(extractUrlAction(pullEvent, baseUrl, identityMap, now)).toBeNull(); + + const commentEvent: HydrantEvent = { + id: 106, + type: 'record', + record: { + did: 'did:plc:alice123', + collection: 'sh.tangled.feed.comment', + rkey: 'comment-1', + action: 'create', + }, + }; + + expect(extractUrlAction(commentEvent, baseUrl, identityMap, now)).toBeNull(); + }); +}); + +describe('Shard Management', () => { + it('formats shard filenames with zero padding', () => { + expect(formatShardFilename(1)).toBe('sitemap-0001.xml.gz'); + expect(formatShardFilename(42)).toBe('sitemap-0042.xml.gz'); + expect(formatShardFilename(1000)).toBe('sitemap-1000.xml.gz'); + }); + + it('seeds static URLs for the initial sitemap', () => { + const urls = getStaticUrls('https://tangled.org'); + expect(urls).toHaveLength(2); + expect(urls.map((u) => u.loc)).toEqual([ + 'https://tangled.org/', + 'https://tangled.org/about', + ]); + }); +}); diff --git a/sitemap/test/sync.test.ts b/sitemap/test/sync.test.ts new file mode 100644 index 000000000..93e2e3810 --- /dev/null +++ b/sitemap/test/sync.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import { parseConfigLimits, syncFromHydrant } from '../src/index'; +import type { Env, HydrantEvent } from '../src/types'; +import { gzipDecompress } from '../src/xml'; + +class MockKV { + store = new Map(); + putCount = 0; + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async put(key: string, value: string): Promise { + this.putCount++; + this.store.set(key, value); + } +} + +class MockR2Object { + constructor(private buffer: ArrayBuffer, public httpMetadata?: any) {} + async arrayBuffer(): Promise { + return this.buffer; + } +} + +class MockR2Bucket { + store = new Map(); + putCount = 0; + + async get(key: string): Promise { + const item = this.store.get(key); + if (!item) return null; + return new MockR2Object(item.buffer, item.httpMetadata); + } + + async put( + key: string, + value: ArrayBuffer | Uint8Array | string, + options?: { httpMetadata?: any }, + ): Promise { + this.putCount++; + let buffer: ArrayBuffer; + if (typeof value === 'string') { + buffer = new TextEncoder().encode(value).buffer as ArrayBuffer; + } else if (value instanceof Uint8Array) { + buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer; + } else { + buffer = value; + } + this.store.set(key, { buffer, httpMetadata: options?.httpMetadata }); + } +} + +describe('Full syncFromHydrant workflow with Sharding & Multi-Sync', () => { + it('handles multi-sync, preserves identity, updates historical shards in-place, and enforces caps', async () => { + const kv = new MockKV(); + const bucket = new MockR2Bucket(); + + // ========================================== + // SYNC 1: Initial creation + multi-shard rollover + // ========================================== + const sync1Events: HydrantEvent[] = [ + { + id: 1, + type: 'identity', + identity: { did: 'did:plc:dawn', handle: 'dawn' }, + }, + { + id: 2, + type: 'record', + record: { + did: 'did:plc:dawn', + collection: 'sh.tangled.actor.profile', + rkey: 'self', + action: 'create', + record: { createdAt: '2026-08-01T00:00:00Z' }, + }, + }, + ]; + + // 58 repo events -> 61 total URLs (2 static + 1 profile + 58 repos) + for (let i = 1; i <= 58; i++) { + sync1Events.push({ + id: 10 + i, + type: 'record', + record: { + did: 'did:plc:dawn', + collection: 'sh.tangled.repo', + rkey: `repo-${i}`, + action: 'create', + record: { + name: `repo-${i}`, + createdAt: '2026-08-15T00:00:00Z', + }, + }, + }); + } + + let currentMockEvents = sync1Events; + const mockHydrantFetcher = { + fetch: async (url: string | Request) => { + const reqUrl = typeof url === 'string' ? url : url.url; + const u = new URL(reqUrl); + const cursor = Number(u.searchParams.get('cursor') || '0'); + const filtered = currentMockEvents.filter((e) => e.id > cursor); + return new Response(JSON.stringify(filtered), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }; + + const env: Env = { + SITEMAP_KV: kv as any, + SITEMAP_BUCKET: bucket as any, + HYDRANT: mockHydrantFetcher as any, + BASE_URL: 'https://tangled.org', + SHARD_MAX_URLS: '25', // 25 max per shard + }; + + const res1 = await syncFromHydrant(env); + expect(res1.success).toBe(true); + expect(res1.syncedEvents).toBe(60); + expect(res1.totalUrls).toBe(61); + expect(res1.newCursor).toBe('68'); + + // Verify identity was persisted in KV + const identityRaw = await kv.get('identity_cache'); + expect(identityRaw).toContain('"did:plc:dawn":"dawn"'); + + // Shards: 25 + 25 + 11 = 61 + const state1Raw = await kv.get('worker_state'); + const state1 = JSON.parse(state1Raw!); + expect(state1.shards).toHaveLength(3); + expect(state1.shards[0].count).toBe(25); + expect(state1.shards[0].sealed).toBe(true); + expect(state1.shards[1].count).toBe(25); + expect(state1.shards[1].sealed).toBe(true); + expect(state1.shards[2].count).toBe(11); + expect(state1.shards[2].sealed).toBe(false); + + // Assert that repo-1 landed in shard 1 + expect(state1.urlLocator['https://tangled.org/dawn/repo-1']).toBe(1); + + // ========================================== + // SYNC 2: Second sync without identity event + // - Asserts no did: URLs are generated (proves handle persistence) + // - Asserts in-place update in historical shard 1 (repo-2) + // - Asserts delete against historical sealed shard 1 (repo-1) + // ========================================== + const sync2Events: HydrantEvent[] = [ + // New repo from same user + { + id: 100, + type: 'record', + record: { + did: 'did:plc:dawn', + collection: 'sh.tangled.repo', + rkey: 'new-tool', + action: 'create', + record: { createdAt: '2026-09-01T00:00:00Z' }, + }, + }, + // Delete repo-1 which is inside sealed shard 1 + { + id: 101, + type: 'record', + record: { + did: 'did:plc:dawn', + collection: 'sh.tangled.repo', + rkey: 'repo-1', + action: 'delete', + }, + }, + // In-place update for repo-2 inside shard 1 + { + id: 102, + type: 'record', + record: { + did: 'did:plc:dawn', + collection: 'sh.tangled.repo', + rkey: 'repo-2', + action: 'update', + record: { createdAt: '2026-09-05T00:00:00Z' }, + }, + }, + ]; + + currentMockEvents = sync2Events; + const res2 = await syncFromHydrant(env); + expect(res2.success).toBe(true); + expect(res2.syncedEvents).toBe(3); + expect(res2.mutatedUrls).toBe(3); + expect(res2.newCursor).toBe('102'); + expect(res2.totalUrls).toBe(61); // -1 deleted, +1 in-place updated, +1 new in active shard + + const state2Raw = await kv.get('worker_state'); + const state2 = JSON.parse(state2Raw!); + + // New URL has resolved handle dawn, NOT did:plc:dawn! + expect(state2.urlLocator['https://tangled.org/dawn/new-tool']).toBe(3); + expect(state2.urlLocator['https://tangled.org/did:plc:dawn/new-tool']).toBeUndefined(); + + // repo-2 remains in shard 1 (in-place update) + expect(state2.urlLocator['https://tangled.org/dawn/repo-2']).toBe(1); + + // Deleted URL is completely gone from locator! + expect(state2.urlLocator['https://tangled.org/dawn/repo-1']).toBeUndefined(); + + // Shard 1 in R2 was updated to 24 items and contains updated repo-2 but NO repo-1 + expect(state2.shards[0].count).toBe(24); + const shard1Obj = await bucket.get('sitemaps/sitemap-0001.xml.gz'); + const shard1Xml = await gzipDecompress(await shard1Obj!.arrayBuffer()); + expect(shard1Xml).not.toContain('https://tangled.org/dawn/repo-1'); + expect(shard1Xml).toContain('https://tangled.org/dawn/repo-2'); + expect(shard1Xml).toContain('2026-09-05'); + + // ========================================== + // SYNC 3: No-op sync pins zero R2 / KV puts! + // ========================================== + const r2PutsBefore = bucket.putCount; + const kvPutsBefore = kv.putCount; + + currentMockEvents = []; + const res3 = await syncFromHydrant(env); + expect(res3.success).toBe(true); + expect(res3.syncedEvents).toBe(0); + expect(res3.mutatedUrls).toBe(0); + + // Explicitly assert zero R2 and KV puts occurred + expect(bucket.putCount).toBe(r2PutsBefore); + expect(kv.putCount).toBe(kvPutsBefore); + + // ========================================== + // SYNC 4: Fetch failure propagation + // ========================================== + mockHydrantFetcher.fetch = async () => new Response('Internal error', { status: 500 }); + const res4 = await syncFromHydrant(env); + expect(res4.success).toBe(false); + expect(res4.error).toContain('HTTP 500'); + expect(res4.newCursor).toBe('102'); // Cursor not falsely advanced + }); + + it('enforces byte-size cap rollover', async () => { + const kv = new MockKV(); + const bucket = new MockR2Bucket(); + + // Tiny 350-byte max cap -> will force rollover after ~2 URLs + const env: Env = { + SITEMAP_KV: kv as any, + SITEMAP_BUCKET: bucket as any, + HYDRANT: { + fetch: async () => new Response(JSON.stringify([ + { + id: 1, + type: 'record', + record: { + did: 'did:plc:alice', + handle: 'alice', + collection: 'sh.tangled.repo', + rkey: 'long-repository-name-to-consume-bytes', + action: 'create', + }, + }, + ])), + } as any, + BASE_URL: 'https://tangled.org', + SHARD_MAX_BYTES: '350', // very low byte limit + }; + + const res = await syncFromHydrant(env); + expect(res.success).toBe(true); + + const stateRaw = await kv.get('worker_state'); + const state = JSON.parse(stateRaw!); + // Shard 1 sealed due to byte cap, shard 2 opened + expect(state.shards.length).toBeGreaterThanOrEqual(2); + expect(state.shards[0].sealed).toBe(true); + }); + + it('validates config limits and handles NaN safely', () => { + expect(parseConfigLimits({ SITEMAP_KV: {} as any, SITEMAP_BUCKET: {} as any, SHARD_MAX_URLS: 'abc' })).toEqual({ + maxUrls: 50000, + maxBytes: 52428800, + }); + expect(parseConfigLimits({ SITEMAP_KV: {} as any, SITEMAP_BUCKET: {} as any, SHARD_MAX_URLS: '-5' })).toEqual({ + maxUrls: 50000, + maxBytes: 52428800, + }); + expect(parseConfigLimits({ SITEMAP_KV: {} as any, SITEMAP_BUCKET: {} as any, SHARD_MAX_URLS: '1000' })).toEqual({ + maxUrls: 1000, + maxBytes: 52428800, + }); + }); +}); diff --git a/sitemap/tsconfig.json b/sitemap/tsconfig.json new file mode 100644 index 000000000..b34a0f034 --- /dev/null +++ b/sitemap/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules"] +} diff --git a/sitemap/wrangler.dev.jsonc b/sitemap/wrangler.dev.jsonc new file mode 100644 index 000000000..a4d6d321e --- /dev/null +++ b/sitemap/wrangler.dev.jsonc @@ -0,0 +1,37 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sitemap-dev", + "main": "src/index.ts", + "workers_dev": false, + "preview_urls": false, + "compatibility_date": "2026-03-07", + "observability": { + "enabled": true, + }, + "triggers": { + "crons": ["0 0 * * *"], + }, + "kv_namespaces": [ + { + "binding": "SITEMAP_KV", + "id": "0b9e74beabe34cf2873b596e7322d379", + }, + ], + "r2_buckets": [ + { + "binding": "SITEMAP_BUCKET", + "bucket_name": "tangled-sitemaps-dev", + }, + ], + "vpc_services": [ + { + "binding": "HYDRANT", + "service_id": "01a05715-1857-7561-abab-104563fe3970", + }, + ], + "vars": { + "BASE_URL": "https://next.tangled.org", + "HYDRANT_URL": "https://api.tangled.org", + "SHARD_MAX_URLS": "50000", + }, +} diff --git a/sitemap/wrangler.jsonc b/sitemap/wrangler.jsonc new file mode 100644 index 000000000..b3844e641 --- /dev/null +++ b/sitemap/wrangler.jsonc @@ -0,0 +1,35 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sitemap", + "main": "src/index.ts", + "compatibility_date": "2026-03-07", + "observability": { + "enabled": true, + }, + "triggers": { + "crons": ["0 0 * * *"], + }, + "kv_namespaces": [ + { + "binding": "SITEMAP_KV", + "id": "sitemap-kv", + }, + ], + "r2_buckets": [ + { + "binding": "SITEMAP_BUCKET", + "bucket_name": "tangled-sitemaps", + }, + ], + "vpc_services": [ + { + "binding": "HYDRANT", + "service_id": "01a05715-1857-7561-abab-104563fe3970", + }, + ], + "vars": { + "BASE_URL": "https://tangled.org", + "HYDRANT_URL": "https://api.tangled.org", + "SHARD_MAX_URLS": "50000", + }, +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 265a3c023..a03311e76 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -23,6 +23,7 @@ declare global { // "0" disables. defaults to 5 MiB SSR_DIFF_MAX_BYTES?: string; BOBBIN?: { fetch(request: Request): Promise }; + SITEMAP?: { fetch(request: Request): Promise }; // verified email → did lookup, populated once by cmd/email-did-migrate EMAIL_DID?: { get(key: string): Promise }; }; diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 3e430aab0..954ae8205 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -12,6 +12,16 @@ export const handle: Handle = async ({ event, resolve }) => { } } + if (event.url.pathname === "/sitemap.xml" || event.url.pathname.startsWith("/sitemaps/")) { + const sitemap = event.platform?.env?.SITEMAP; + if (sitemap) { + const upstream = new URL(event.request.url); + upstream.hostname = "sitemap.internal"; + return sitemap.fetch(new Request(upstream, event.request)); + } + console.warn("[hooks.server] SITEMAP service binding not configured; sitemap request unhandled"); + } + return resolve(event, { // sveltekit blocks atcute fetch handler from reading headers // because it assumes backend APIs might return sensitive headers. diff --git a/web/wrangler.dev.jsonc b/web/wrangler.dev.jsonc index 82fa0c288..c8f301482 100644 --- a/web/wrangler.dev.jsonc +++ b/web/wrangler.dev.jsonc @@ -4,7 +4,9 @@ "main": ".svelte-kit/cloudflare/_worker.js", "workers_dev": false, "compatibility_date": "2026-07-30", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": [ + "nodejs_compat" + ], "placement": { "region": "aws:eu-central-1" }, @@ -14,12 +16,18 @@ "assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare", - "run_worker_first": ["/oauth-client-metadata.json"] + "run_worker_first": [ + "/oauth-client-metadata.json" + ] }, "services": [ { "binding": "BOBBIN", "service": "bobbin-svfe-dev" + }, + { + "binding": "SITEMAP", + "service": "sitemap-dev" } ], "kv_namespaces": [ -- 2.51.2