diff --git a/bun.lock b/bun.lock index 51f8e99..5942e29 100644 --- a/bun.lock +++ b/bun.lock @@ -210,6 +210,22 @@ "tailwindcss-animate": "^1.0.7", }, }, + "packages/devhook": { + "name": "@blink-sdk/devhook", + "version": "0.0.1", + "dependencies": { + "@blink-sdk/multiplexer": "workspace:*", + "ws": "^8.18.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "@types/ws": "^8.5.10", + "hono": "^4.7.10", + "tsdown": "^0.11.12", + "vitest": "^3.2.4", + "wrangler": "^4.22.0", + }, + }, "packages/events": { "name": "@blink-sdk/events", }, @@ -550,6 +566,8 @@ "@blink-sdk/compute-protocol": ["@blink-sdk/compute-protocol@workspace:packages/compute-protocol"], + "@blink-sdk/devhook": ["@blink-sdk/devhook@workspace:packages/devhook"], + "@blink-sdk/events": ["@blink-sdk/events@workspace:packages/events"], "@blink-sdk/github": ["@blink-sdk/github@workspace:packages/github"], @@ -594,10 +612,26 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.13", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251202.0" }, "optionalPeers": ["workerd"] }, "sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251210.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251210.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251210.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251210.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251210.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251211.0", "", {}, "sha512-e87o6KbelCz7dnI5ngrGT2ca15vJZ+COb2eqJ52iDHmOaujyC6aYZ71e2vor8X6V9T6tcDElC5sAqPR93j09EA=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@daytonaio/api-client": ["@daytonaio/api-client@0.117.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-xXowLiOH6KXiQ83gQAlXCaNB6MV3FA8e3dYx4aUerxiWnSI2WO638LLvHrw6i4MsAfweRgIEdOtEuWEpZMJVBw=="], "@daytonaio/sdk": ["@daytonaio/sdk@0.117.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytonaio/api-client": "0.117.0", "@daytonaio/toolbox-api-client": "0.117.0", "@iarna/toml": "^2.2.5", "axios": "^1.11.0", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^6.2.0" } }, "sha512-Ub9ttABhDJRuz0j3irHCh6kwTfZ0hpDsbl5dBf8l37bZz2tYMhyUlacjBmKOnhoP+/a8NiXdTh2euI5i/U+RHw=="], @@ -1170,6 +1204,12 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@posthog/core": ["@posthog/core@1.7.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -1470,6 +1510,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.12", "", {}, "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1684,7 +1726,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], + "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -1766,6 +1808,10 @@ "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], @@ -1794,11 +1840,11 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], - "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1806,7 +1852,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@5.0.110", "", { "dependencies": { "@ai-sdk/gateway": "2.0.19", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZBq+5bvef4e5qoIG4U6NJ1UpCPWGjuaWERHXbHu2T2ND3c02nJ2zlnjm+N6zAAplQPxwqm7Sb16mrRX5uQNWtQ=="], + "ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -1894,6 +1940,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "blink": ["blink@workspace:packages/blink"], "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], @@ -2040,10 +2088,14 @@ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -2344,6 +2396,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -2392,8 +2446,12 @@ "execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exponential-backoff": ["exponential-backoff@3.1.2", "", {}, "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA=="], "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], @@ -2522,6 +2580,8 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], @@ -2694,6 +2754,8 @@ "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], @@ -2820,6 +2882,8 @@ "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "ky": ["ky@1.11.0", "", {}, "sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag=="], "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], @@ -3028,7 +3092,7 @@ "miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": { "miller-rabin": "bin/miller-rabin" } }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -3042,6 +3106,8 @@ "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], + "miniflare": ["miniflare@4.20251210.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251210.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], "minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="], @@ -3562,10 +3628,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-git": ["simple-git@3.28.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w=="], + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], @@ -3612,10 +3682,16 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + "storybook": ["storybook@9.1.16", "", { "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "better-opn": "^3.0.2", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "esbuild-register": "^3.5.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./bin/index.cjs" }, "sha512-339U14K6l46EFyRvaPS2ZlL7v7Pb+LlcXT8KAETrGPxq8v1sAjj2HAOB6zrlAK3M+0+ricssfAwsLCwt7Eg8TQ=="], "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], @@ -3650,6 +3726,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], @@ -3710,6 +3788,8 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], @@ -3718,6 +3798,8 @@ "tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], @@ -3792,6 +3874,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -3878,6 +3962,8 @@ "vite-hot-client": ["vite-hot-client@2.1.0", "", { "peerDependencies": { "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" } }, "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-plugin-inspect": ["vite-plugin-inspect@11.3.3", "", { "dependencies": { "ansis": "^4.1.0", "debug": "^4.4.1", "error-stack-parser-es": "^1.0.5", "ohash": "^2.0.11", "open": "^10.2.0", "perfect-debounce": "^2.0.0", "sirv": "^3.0.1", "unplugin-utils": "^0.3.0", "vite-dev-rpc": "^1.1.0" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0-0" } }, "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA=="], "vite-plugin-node-polyfills": ["vite-plugin-node-polyfills@0.24.0", "", { "dependencies": { "@rollup/plugin-inject": "^5.0.5", "node-stdlib-browser": "^1.2.0" }, "peerDependencies": { "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw=="], @@ -3886,6 +3972,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="], "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -3924,10 +4012,16 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "workerd": ["workerd@1.20251210.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251210.0", "@cloudflare/workerd-darwin-arm64": "1.20251210.0", "@cloudflare/workerd-linux-64": "1.20251210.0", "@cloudflare/workerd-linux-arm64": "1.20251210.0", "@cloudflare/workerd-windows-64": "1.20251210.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw=="], + + "wrangler": ["wrangler@4.54.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.13", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20251210.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251210.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251210.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3968,7 +4062,11 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], @@ -4028,10 +4126,14 @@ "@babel/traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@blink-sdk/devhook/tsdown": ["tsdown@0.11.13", "", { "dependencies": { "ansis": "^4.0.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "debug": "^4.4.1", "diff": "^8.0.1", "empathic": "^1.1.0", "hookable": "^5.5.3", "rolldown": "1.0.0-beta.9", "rolldown-plugin-dts": "^0.13.3", "semver": "^7.7.2", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.13", "unconfig": "^7.3.2" }, "peerDependencies": { "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.js" } }, "sha512-VSfoNm8MJXFdg7PJ4p2javgjMRiQQHpkP9N3iBBTrmCixcT6YZ9ZtqYMW3NDHczqR0C0Qnur1HMQr1ZfZcmrng=="], + "@blink-sdk/github/file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], "@blink-sdk/scout-agent/tsdown": ["tsdown@0.3.1", "", { "dependencies": { "cac": "^6.7.14", "chokidar": "^4.0.1", "consola": "^3.2.3", "debug": "^4.3.7", "picocolors": "^1.1.1", "pkg-types": "^1.2.1", "rolldown": "nightly", "tinyglobby": "^0.2.10", "unconfig": "^0.6.0", "unplugin-isolated-decl": "^0.7.2", "unplugin-unused": "^0.2.3" }, "bin": { "tsdown": "bin/tsdown.js" } }, "sha512-5WLFU7f2NRnsez0jxi7m2lEQNPvBOdos0W8vHvKDnS6tYTfOfmZ5D2z/G9pFTQSjeBhoi6BFRMybc4LzCOKR8A=="], + "@blink.so/api/zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "@blink.so/compute-protocol-worker/@blink-sdk/compute-protocol": ["@blink-sdk/compute-protocol@0.0.2", "", { "peerDependencies": { "ws": ">= 8", "zod": ">= 4" } }, "sha512-QD89Y4b3EbZjncROb6kwUr1uQV4N3UD9q7Hp2PzL4A2BAzsqk50w7KfN9RxfDiZ3fU7Pectg71T4M8ZCwdJcdQ=="], "@blink.so/site/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -4042,12 +4144,16 @@ "@blink/desktop/@blink.so/api": ["@blink.so/api@0.0.11", "", { "optionalDependencies": { "@blink-sdk/compute-protocol": ">= 0.0.2" }, "peerDependencies": { "ai": ">= 5", "react": ">= 18", "zod": ">= 4" }, "optionalPeers": ["react"] }, "sha512-4JW0fsGFn8IN5r+FpdbkqXkFqyCXQ8sDXoETdIBczLe3/+JP0Q2ItvN9XtR/eLNIshIL9Yz+gZtB6AVWQIcIWg=="], + "@blink/desktop/ai": ["ai@5.0.110", "", { "dependencies": { "@ai-sdk/gateway": "2.0.19", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZBq+5bvef4e5qoIG4U6NJ1UpCPWGjuaWERHXbHu2T2ND3c02nJ2zlnjm+N6zAAplQPxwqm7Sb16mrRX5uQNWtQ=="], + "@blink/desktop/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "@blink/desktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], "@blink/desktop/react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -4314,6 +4420,10 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="], + + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -4430,6 +4540,10 @@ "aggregate-error/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -4628,6 +4742,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "import-in-the-middle/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "importx/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "jsonwebtoken/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -4640,6 +4756,8 @@ "less/image-size": ["image-size@0.5.5", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ=="], + "less/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -4672,6 +4790,14 @@ "miller-rabin/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -4684,6 +4810,8 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4830,6 +4958,8 @@ "type-is/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "unplugin/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "update-notifier/boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], "update-notifier/is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], @@ -4842,12 +4972,20 @@ "vite-plugin-storybook-nextjs/@next/env": ["@next/env@16.0.0", "", {}, "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA=="], + "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "webpack-bundle-analyzer/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "webpack-bundle-analyzer/acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "webpack-bundle-analyzer/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "webpack-bundle-analyzer/sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="], "webpack-bundle-analyzer/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -4878,6 +5016,12 @@ "@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@blink-sdk/devhook/tsdown/empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="], + + "@blink-sdk/devhook/tsdown/rolldown": ["rolldown@1.0.0-beta.9", "", { "dependencies": { "@oxc-project/types": "0.70.0", "@rolldown/pluginutils": "1.0.0-beta.9", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9", "@rolldown/binding-darwin-x64": "1.0.0-beta.9", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9" }, "peerDependencies": { "@oxc-project/runtime": "0.70.0" }, "optionalPeers": ["@oxc-project/runtime"], "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZgZky52n6iF0UainGKjptKGrOG4Con2S5sdc4C4y2Oj25D5PHAY8Y8E5f3M2TSd/zlhQs574JlMeTe3vREczSg=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts": ["rolldown-plugin-dts@0.13.14", "", { "dependencies": { "@babel/generator": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/types": "^7.28.1", "ast-kit": "^2.1.1", "birpc": "^2.5.0", "debug": "^4.4.1", "dts-resolver": "^2.1.1", "get-tsconfig": "^4.10.1" }, "peerDependencies": { "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.9", "typescript": "^5.0.0", "vue-tsc": "^2.2.0 || ^3.0.0" }, "optionalPeers": ["@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-wjNhHZz9dlN6PTIXyizB6u/mAg1wEFMW9yw7imEVe3CxHSRnNHVyycIX0yDEOVJfDNISLPbkCIPEpFpizy5+PQ=="], + "@blink-sdk/github/file-type/strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "@blink-sdk/scout-agent/tsdown/rolldown": ["rolldown@1.0.0-beta.13-commit.024b632", "", { "dependencies": { "@oxc-project/runtime": "=0.72.3", "@oxc-project/types": "=0.72.3", "@rolldown/pluginutils": "1.0.0-beta.13-commit.024b632", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-darwin-x64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-freebsd-x64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.13-commit.024b632" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-sntAHxNJ22WdcXVHQDoRst4eOJZjuT3S1aqsNWsvK2aaFVPgpVPY3WGwvJ91SvH/oTdRCyJw5PwpzbaMdKdYqQ=="], @@ -5674,6 +5818,44 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + "node-stdlib-browser/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -5870,6 +6052,58 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -5890,6 +6124,40 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@blink-sdk/devhook/tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.70.0", "", {}, "sha512-ngyLUpUjO3dpqygSRQDx7nMx8+BmXbWOU4oIwTJFV2MVIDG7knIZwgdwXlQWLg3C3oxg1lS7ppMtPKqKFb7wzw=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-geUG/FUpm+membLC0NQBb39vVyOfguYZ2oyXc7emr6UjH6TeEECT4b0CPZXKFnELareTiU/Jfl70/eEgNxyQeA=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7wPXDwcOtv2I+pWTL2UNpNAxMAGukgBT90Jz4DCfwaYdGvQncF7J0S7IWrRVsRFhBavxM+65RcueE3VXw5UIbg=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-agO5mONTNKVrcIt4SRxw5Ni0FOVV3gaH8dIiNp1A4JeU91b9kw7x+JRuNJAQuM2X3pYqVvA6qh13UTNOsaqM/Q=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm" }, "sha512-dDNDV9p/8WYDriS9HCcbH6y6+JP38o3enj/pMkdkmkxEnZ0ZoHIfQ9RGYWeRYU56NKBCrya4qZBJx49Jk9LRug=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-kZKegmHG1ZvfsFIwYU6DeFSxSIcIliXzeznsJHUo9D9/dlVSDi/PUvsRKcuJkQjZoejM6pk8MHN/UfgGdIhPHw=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-f+VL8mO31pyMJiJPr2aA1ryYONkP2UqgbwK7fKtKHZIeDd/AoUGn3+ujPqDhuy2NxgcJ5H8NaSvDpG1tJMHh+g=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.9", "", { "os": "linux", "cpu": "x64" }, "sha512-GiUEZ0WPjX5LouDoC3O8aJa4h6BLCpIvaAboNw5JoRour/3dC6rbtZZ/B5FC3/ySsN3/dFOhAH97ylQxoZJi7A=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.9", "", { "os": "linux", "cpu": "x64" }, "sha512-AMb0dicw+QHh6RxvWo4BRcuTMgS0cwUejJRMpSyIcHYnKTbj6nUW4HbWNQuDfZiF27l6F5gEwBS+YLUdVzL9vg=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-+pdaiTx7L8bWKvsAuCE0HAxP1ze1WOLoWGCawcrZbMSY10dMh2i82lJiH6tXGXbfYYwsNWhWE2NyG4peFZvRfQ=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-A7kN248viWvb8eZMzQu024TBKGoyoVYBsDG2DtoP8u2pzwoh5yDqUL291u01o4f8uzpUHq8mfwQJmcGChFu8KQ=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-DzKN7iEYjAP8AK8F2G2aCej3fk43Y/EQrVrR3gF0XREes56chjQ7bXIhw819jv74BbxGdnpPcslhet/cgt7WRA=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "x64" }, "sha512-GMWgTvvbZ8TfBsAiJpoz4SRq3IN3aUMn0rYm8q4I8dcEk4J1uISyfb6ZMzvqW+cvScTWVKWZNqnrmYOKLLUt4w=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.72.3", "", {}, "sha512-CfAC4wrmMkUoISpQkFAIfMVvlPfQV3xg7ZlcqPXPOIMQhdKIId44G8W0mCPgtpWdFFAyJ+SFtiM+9vbyCkoVng=="], "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.13-commit.024b632", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dkMfisSkfS3Rbyj+qL6HFQmGNlwCKhkwH7pKg2oVhzpEQYnuP0YIUGV4WXsTd3hxoHNgs+LQU5LJe78IhE2q6g=="], @@ -6030,6 +6298,10 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@npmcli/move-file/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -6046,6 +6318,8 @@ "iconv-corefoundation/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], "blink/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], diff --git a/packages/devhook/README.md b/packages/devhook/README.md new file mode 100644 index 0000000..a700ca3 --- /dev/null +++ b/packages/devhook/README.md @@ -0,0 +1,231 @@ +# @blink-sdk/devhook + +Expose local servers via a public URL. Perfect for webhooks, API testing, and development. + +## Features + +- **Secure URLs**: Client secrets are signed with HMAC-SHA256 to generate deterministic, unguessable subdomains +- **Flexible routing**: Support for wildcard subdomains (`abc123.devhook.example.com`) or subpath routing (`example.com/devhook/abc123`) +- **WebSocket support**: Full bidirectional WebSocket proxying +- **Persistent sessions**: Durable Object state survives restarts +- **Local testing**: Run the server locally with Node.js + +## Installation + +```bash +npm install @blink-sdk/devhook +``` + +## Client Usage + +```typescript +import { DevhookClient } from "@blink-sdk/devhook"; + +const client = new DevhookClient({ + serverUrl: "https://devhook.example.com", + secret: "my-secret-key", + // Transform WebSocket requests to point to your local server + transformWebSocketRequest: ({ url, headers }) => { + url.host = "localhost:3000"; + return { url, headers }; + }, + // Handle HTTP requests + onRequest: async (req) => { + const url = new URL(req.url); + url.host = "localhost:3000"; + return fetch(new Request(url.toString(), req)); + }, + onConnect: ({ url, id }) => { + console.log(`Devhook available at: ${url}`); + console.log(`Devhook ID: ${id}`); + }, + onDisconnect: () => { + console.log("Disconnected from server"); + }, + onError: (error) => { + console.error("Error:", error); + }, +}); + +const disposable = client.connect(); + +// When done: +// disposable.dispose(); +``` + +## Server Deployment + +### Cloudflare Workers (Production) + +1. Clone this repository or copy the server files +2. Configure `wrangler.toml`: + +```toml +name = "devhook-server" +main = "src/server/cloudflare.ts" +compatibility_date = "2025-01-01" + +# For wildcard subdomains: +routes = [ + { pattern = "*.devhook.example.com/*", zone_name = "example.com" }, + { pattern = "devhook.example.com/*", zone_name = "example.com" } +] + +[vars] +DEVHOOK_SECRET = "your-secure-server-secret" +DEVHOOK_BASE_URL = "https://devhook.example.com" +DEVHOOK_MODE = "wildcard" + +[[durable_objects.bindings]] +name = "DEVHOOK_SESSION" +class_name = "DevhookSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["DevhookSession"] +``` + +3. Deploy: + +```bash +wrangler deploy +``` + +### DNS Configuration (Wildcard Mode) + +For wildcard subdomains, configure your DNS: + +1. Add a wildcard CNAME record: `*.devhook.example.com` → your Cloudflare zone +2. Or use Cloudflare's automatic proxying + +### Local Development + +```typescript +import { createLocalServer } from "@blink-sdk/devhook/server/local"; + +const server = createLocalServer({ + port: 8080, + secret: "server-secret", + baseUrl: "http://localhost:8080", + mode: "subpath", // Easier for local testing + onReady: (port) => { + console.log(`Devhook server running on port ${port}`); + }, + onClientConnect: (id) => { + console.log(`Client connected: ${id}`); + }, + onClientDisconnect: (id) => { + console.log(`Client disconnected: ${id}`); + }, +}); + +// Later: server.close(); +``` + +## How It Works + +### URL Generation + +1. Client provides a secret +2. Server signs the secret with HMAC-SHA256 using its own secret +3. The signature is base64url-encoded and truncated to 16 characters +4. This becomes the devhook ID (subdomain or path prefix) + +This means: + +- The same client secret always produces the same URL +- URLs cannot be guessed without knowing the client secret +- Different server secrets produce different URLs + +### Request Flow + +``` +External Request + │ + ▼ +┌─────────────────┐ +│ Cloudflare Edge │ +│ (Worker) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Durable Object │ +│ (Session State) │ +└────────┬────────┘ + │ WebSocket + ▼ +┌─────────────────┐ +│ Devhook Client │ +│ (Your Machine) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Local Server │ +│ (localhost:3000)│ +└─────────────────┘ +``` + +## API Reference + +### DevhookClient + +```typescript +interface DevhookClientOptions { + /** The devhook server URL */ + serverUrl: string; + + /** Client secret for URL generation */ + secret: string; + + /** Transform WebSocket requests before proxying to local server */ + transformWebSocketRequest?: (request: { + url: URL; + headers: Record; + }) => { url: URL; headers: Record }; + + /** Handle incoming proxied HTTP requests */ + onRequest: (request: Request) => Promise; + + /** Called when connected (with public URL) */ + onConnect?: (info: { url: string; id: string }) => void; + + /** Called when disconnected */ + onDisconnect?: () => void; + + /** Called on error */ + onError?: (error: unknown) => void; +} +``` + +### createLocalServer + +```typescript +interface LocalServerOptions { + /** Port to listen on */ + port: number; + + /** Server secret for HMAC signing */ + secret: string; + + /** Base URL for generating public URLs */ + baseUrl: string; + + /** Routing mode: "wildcard" or "subpath" */ + mode?: "wildcard" | "subpath"; + + /** Called when server starts */ + onReady?: (port: number) => void; + + /** Called when client connects */ + onClientConnect?: (id: string) => void; + + /** Called when client disconnects */ + onClientDisconnect?: (id: string) => void; +} +``` + +## License + +MIT diff --git a/packages/devhook/examples/client.ts b/packages/devhook/examples/client.ts new file mode 100644 index 0000000..f6b38c2 --- /dev/null +++ b/packages/devhook/examples/client.ts @@ -0,0 +1,78 @@ +/** + * Example devhook client that proxies requests to localhost:8000. + * + * Run with: npx tsx examples/client.ts + * + * Make sure you have: + * 1. The devhook server running (npx tsx examples/server.ts) + * 2. A local server running on port 8000 (e.g., python -m http.server 8000) + */ + +import { DevhookClient } from "../src/client"; + +const SERVER_URL = "http://localhost:8080"; +const CLIENT_SECRET = "example-client-secret"; +const LOCAL_SERVER_PORT = 8000; + +const client = new DevhookClient({ + serverUrl: SERVER_URL, + secret: CLIENT_SECRET, + onRequest: async (request) => { + // Forward requests to the local server + const url = new URL(request.url); + url.host = `localhost:${LOCAL_SERVER_PORT}`; + url.protocol = "http:"; + + const newRequest = new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error duplex is needed for streaming bodies + duplex: "half", + }); + + try { + return await fetch(newRequest); + } catch (error) { + console.error("Error forwarding request:", error); + return new Response( + JSON.stringify({ error: "Failed to connect to local server" }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } + }, + onConnect: ({ url, id }) => { + console.log(`Connected to devhook server!`); + console.log(`Public URL: ${url}`); + console.log(`Devhook ID: ${id}`); + console.log( + `\nRequests to ${url}/* will be proxied to http://localhost:${LOCAL_SERVER_PORT}/*` + ); + }, + onDisconnect: () => { + console.log("Disconnected from devhook server"); + }, + onError: (error) => { + console.error("Devhook error:", error); + }, +}); + +console.log(`Connecting to devhook server at ${SERVER_URL}...`); +console.log(`Will proxy requests to http://localhost:${LOCAL_SERVER_PORT}`); + +const disposable = client.connect(); + +// Handle graceful shutdown +process.on("SIGINT", () => { + console.log("\nDisconnecting..."); + disposable.dispose(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + disposable.dispose(); + process.exit(0); +}); diff --git a/packages/devhook/examples/server.ts b/packages/devhook/examples/server.ts new file mode 100644 index 0000000..7ec8614 --- /dev/null +++ b/packages/devhook/examples/server.ts @@ -0,0 +1,40 @@ +/** + * Example devhook server for local testing. + * + * Run with: npx tsx examples/server.ts + */ + +import { createLocalServer } from "../src/server/local"; + +const PORT = 8080; +const SERVER_SECRET = "example-server-secret"; + +const { close } = createLocalServer({ + port: PORT, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${PORT}`, + mode: "subpath", + onReady: (port) => { + console.log(`Devhook server running on http://localhost:${port}`); + console.log(`Waiting for clients to connect...`); + }, + onClientConnect: (id) => { + console.log(`Client connected: ${id}`); + console.log(`Public URL: http://localhost:${PORT}/devhook/${id}`); + }, + onClientDisconnect: (id) => { + console.log(`Client disconnected: ${id}`); + }, +}); + +// Handle graceful shutdown +process.on("SIGINT", () => { + console.log("\nShutting down server..."); + close(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + close(); + process.exit(0); +}); diff --git a/packages/devhook/package.json b/packages/devhook/package.json new file mode 100644 index 0000000..f8b9f8f --- /dev/null +++ b/packages/devhook/package.json @@ -0,0 +1,55 @@ +{ + "name": "@blink-sdk/devhook", + "description": "Devhook - expose local servers via a public URL", + "version": "0.0.1", + "type": "module", + "keywords": [ + "blink", + "devhook", + "tunnel", + "proxy", + "webhook" + ], + "publishConfig": { + "access": "public" + }, + "author": "Coder", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/coder/blink.git" + }, + "homepage": "https://github.com/coder/blink/tree/main/packages/devhook", + "bugs": { + "url": "https://github.com/coder/blink/issues" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "typecheck": "tsgo --noEmit", + "test": "vitest run src/devhook.test.ts", + "test:cloudflare": "vitest run src/cloudflare.test.ts", + "test:all": "vitest run", + "test:watch": "vitest" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@blink-sdk/multiplexer": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "@types/ws": "^8.5.10", + "hono": "^4.7.10", + "tsdown": "^0.11.12", + "vitest": "^3.2.4", + "wrangler": "^4.22.0" + } +} diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts new file mode 100644 index 0000000..2e47246 --- /dev/null +++ b/packages/devhook/src/client/index.ts @@ -0,0 +1,545 @@ +import Multiplexer, { type Stream } from "@blink-sdk/multiplexer"; +import WebSocket from "ws"; +import { type Disposable } from "../emitter"; +import { + ClientMessageType, + ServerMessageType, + createWebSocketMessagePayload, + parseWebSocketMessagePayload, + type ConnectionEstablished, + type ProxyInitRequest, + type ProxyInitResponse, + type WebSocketClosePayload, +} from "../schema"; + +/** + * Represents a WebSocket connection request that can be transformed + * before the connection is established to the local server. + */ +export interface WebSocketRequest { + url: URL; + headers: Record; +} + +export interface DevhookClientOptions { + /** + * The devhook server URL. + * For wildcard mode: https://devhook.example.com + * For subpath mode: https://example.com + */ + serverUrl: string; + + /** + * Client secret used to generate a secure, deterministic subdomain. + * The server signs this with HMAC-SHA256 to create the public URL. + */ + secret: string; + + /** + * Handle incoming proxied HTTP requests. + * Return a Response to send back to the original requester. + */ + onRequest: (request: Request) => Promise; + + /** + * Called when the connection is established. + * Receives the public URL that can be used to access this devhook. + */ + onConnect?: (info: ConnectionEstablished) => void; + + /** + * Called when the connection is lost. + */ + onDisconnect?: () => void; + + /** + * Called when an error occurs. + */ + onError?: (error: unknown) => void; + + /** + * Transform WebSocket connection requests before they are proxied to the local server. + * Allows modifying both the target URL and headers. + * If not provided, WebSocket requests are proxied as-is. + * + * Note: This only applies to WebSocket connections. HTTP requests should be + * transformed in the `onRequest` callback. + * + * @example + * ```ts + * transformWebSocketRequest: ({ url, headers }) => { + * url.host = "localhost:3000"; + * return { url, headers }; + * } + * ``` + */ + transformWebSocketRequest?: (request: WebSocketRequest) => WebSocketRequest; +} + +/** + * Connect to a devhook server and handle proxied requests. + * + * @example + * ```ts + * const client = new DevhookClient({ + * serverUrl: "https://devhook.example.com", + * secret: "my-secret-key", + * onRequest: async (req) => { + * // Forward to local server + * const url = new URL(req.url); + * url.host = "localhost:3000"; + * return fetch(new Request(url.toString(), req)); + * }, + * onConnect: ({ url }) => { + * console.log(`Devhook available at: ${url}`); + * }, + * }); + * + * const disposable = client.connect(); + * // Later: disposable.dispose(); + * ``` + */ +export class DevhookClient { + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + + constructor(private readonly opts: DevhookClientOptions) {} + + /** + * Connect to the devhook server. + * Returns a Disposable that can be used to disconnect. + */ + connect(): Disposable { + let socket: WebSocket | undefined; + let reconnectTimeout: ReturnType | undefined; + let disposed = false; + let multiplexer: Multiplexer | undefined; + + // Exponential backoff with jitter + const baseDelayMS = 250; + const maxDelayMS = 10_000; + let currentDelayMS = baseDelayMS; + + const clearReconnectTimer = () => { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = undefined; + } + }; + + const scheduleReconnect = () => { + if (disposed) return; + clearReconnectTimer(); + const jitter = currentDelayMS * 0.2 * Math.random(); + const delay = Math.min(maxDelayMS, Math.floor(currentDelayMS + jitter)); + reconnectTimeout = setTimeout(() => { + openSocket(); + }, delay); + currentDelayMS = Math.min(maxDelayMS, Math.floor(currentDelayMS * 1.5)); + }; + + const resetBackoff = () => { + currentDelayMS = baseDelayMS; + }; + + const openSocket = () => { + if (disposed) return; + + try { + const wsUrl = new URL(this.opts.serverUrl); + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + wsUrl.pathname = "/api/devhook/connect"; + + socket = new WebSocket(wsUrl.toString(), { + headers: { + "x-devhook-secret": this.opts.secret, + }, + }); + socket.binaryType = "arraybuffer"; + + multiplexer = new Multiplexer({ + send: (data: Uint8Array) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(data); + } + }, + }); + + multiplexer.onStream((stream: Stream) => { + this.handleStream(stream); + }); + + socket.on("open", () => { + if (disposed) return; + resetBackoff(); + }); + + socket.on("message", (data: ArrayBuffer | Buffer) => { + if (disposed) return; + + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + // Check if this is a connection established message (JSON) + // Connection messages are sent as text, not binary multiplexed data + if (bytes.length > 0 && bytes[0] === 0x7b) { + // '{' character + try { + const text = this.decoder.decode(bytes); + const msg = JSON.parse(text) as ConnectionEstablished; + if (msg.url && msg.id) { + this.opts.onConnect?.(msg); + return; + } + } catch { + // Not JSON, continue with binary handling + } + } + + multiplexer?.handleMessage(bytes); + }); + + socket.on("close", () => { + if (disposed) return; + multiplexer = undefined; + this.opts.onDisconnect?.(); + scheduleReconnect(); + }); + + socket.on("error", (err) => { + try { + this.opts.onError?.(err); + } catch { + // Ignore errors from error handler + } + try { + socket?.close(); + } catch { + // Ignore close errors + } + }); + } catch (err) { + try { + this.opts.onError?.(err); + } catch { + // Ignore errors from error handler + } + scheduleReconnect(); + } + }; + + openSocket(); + + return { + dispose: () => { + if (disposed) return; + disposed = true; + clearReconnectTimer(); + const ws = socket; + socket = undefined; + multiplexer = undefined; + try { + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + ws.close(1000); + } + } catch { + // Ignore close errors + } + }, + }; + } + + /** + * Handle a new stream from the multiplexer. + * Each stream represents a single proxied request. + */ + private handleStream(stream: Stream): void { + let requestInit: ProxyInitRequest | undefined; + let bodyWriter: WritableStreamDefaultWriter | undefined; + let bodyStream: ReadableStream | undefined; + let isWebSocket = false; + + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ServerMessageType.PROXY_INIT: { + requestInit = JSON.parse( + this.decoder.decode(payload) + ) as ProxyInitRequest; + isWebSocket = requestInit.headers["upgrade"] === "websocket"; + + if (!isWebSocket) { + // Set up body stream for non-WebSocket requests + const transform = new TransformStream(); + bodyWriter = transform.writable.getWriter(); + bodyStream = transform.readable; + + // Process the request + this.handleProxyRequest(stream, requestInit, bodyStream); + } else { + // Handle WebSocket upgrade + this.handleProxyWebSocket(stream, requestInit); + } + break; + } + + case ServerMessageType.PROXY_BODY: { + if (bodyWriter) { + if (payload.length === 0) { + // Empty chunk signals end of body + bodyWriter.close().catch(() => {}); + } else { + bodyWriter.write(payload).catch(() => {}); + } + } + break; + } + + case ServerMessageType.PROXY_WEBSOCKET_MESSAGE: { + // WebSocket messages are handled by the WebSocket handler + break; + } + + case ServerMessageType.PROXY_WEBSOCKET_CLOSE: { + // WebSocket close is handled by the WebSocket handler + break; + } + } + }); + } + + /** + * Handle a proxied HTTP request. + */ + private async handleProxyRequest( + stream: Stream, + init: ProxyInitRequest, + body: ReadableStream + ): Promise { + try { + const hasBody = + init.method !== "GET" && + init.method !== "HEAD" && + init.method !== "OPTIONS"; + + const request = new Request(init.url, { + method: init.method, + headers: init.headers, + body: hasBody ? body : undefined, + // @ts-expect-error - Required for Node.js streaming + duplex: hasBody ? "half" : undefined, + }); + + const response = await this.opts.onRequest(request); + + // Send response headers + const headers: Record = {}; + response.headers.forEach((value, key) => { + // Skip Set-Cookie - handled separately to preserve multiple cookies + if (key.toLowerCase() !== "set-cookie") { + headers[key] = value; + } + }); + + // Extract Set-Cookie headers separately (preserves multiple cookies) + const setCookies = response.headers.getSetCookie(); + + const proxyInit: ProxyInitResponse = { + status_code: response.status, + status_message: response.statusText, + headers, + set_cookies: setCookies.length > 0 ? setCookies : undefined, + }; + + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + + // Stream response body + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + stream.writeTyped(ClientMessageType.PROXY_DATA, value); + } + } + } finally { + reader.releaseLock(); + } + } + + stream.close(); + } catch (err) { + // Send error response + const proxyInit: ProxyInitResponse = { + status_code: 502, + status_message: "Bad Gateway", + headers: { "content-type": "text/plain" }, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + stream.writeTyped( + ClientMessageType.PROXY_DATA, + this.encoder.encode( + `Error proxying request: ${err instanceof Error ? err.message : String(err)}` + ) + ); + stream.close(); + } + } + + /** + * Handle a proxied WebSocket connection. + */ + private async handleProxyWebSocket( + stream: Stream, + init: ProxyInitRequest + ): Promise { + try { + // Transform the WebSocket request (URL and headers) before connecting + let targetUrl = new URL(init.url); + let targetHeaders = { ...init.headers }; + + if (this.opts.transformWebSocketRequest) { + const transformed = this.opts.transformWebSocketRequest({ + url: targetUrl, + headers: targetHeaders, + }); + targetUrl = transformed.url; + targetHeaders = transformed.headers; + } + + targetUrl.protocol = targetUrl.protocol === "https:" ? "wss:" : "ws:"; + + const ws = new WebSocket( + targetUrl.toString(), + targetHeaders["sec-websocket-protocol"], + { + headers: targetHeaders, + perMessageDeflate: false, + } + ); + + ws.on("open", () => { + const proxyInit: ProxyInitResponse = { + status_code: 101, + status_message: "Switching Protocols", + headers: {}, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + }); + + ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { + const payload = + data instanceof ArrayBuffer + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : data; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_MESSAGE, + createWebSocketMessagePayload(payload, this.encoder) + ); + }); + + ws.on("close", (code, reason) => { + try { + const closePayload: WebSocketClosePayload = { + code, + reason: reason.toString(), + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + } catch { + // Stream may already be disposed, ignore + } + }); + + ws.on("error", (err) => { + try { + const closePayload: WebSocketClosePayload = { + code: 1011, + reason: err.message, + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + } catch { + // Stream may already be disposed, ignore + } + }); + + // Handle messages from the server to forward to the local WebSocket + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ServerMessageType.PROXY_WEBSOCKET_MESSAGE: { + const parsed = parseWebSocketMessagePayload(payload, this.decoder); + ws.send(parsed); + break; + } + case ServerMessageType.PROXY_WEBSOCKET_CLOSE: { + try { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + // ws.close requires a valid code (1000 or 3000-4999) or no arguments + const code = closePayload.code; + if (code !== undefined && code >= 1000 && code <= 4999) { + ws.close(code, closePayload.reason); + } else { + ws.close(); + } + } catch { + ws.close(); + } + break; + } + } + }); + + stream.onClose(() => { + ws.close(); + }); + } catch (err) { + const proxyInit: ProxyInitResponse = { + status_code: 502, + status_message: "Bad Gateway", + headers: {}, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + stream.close(); + } + } +} diff --git a/packages/devhook/src/cloudflare.test.ts b/packages/devhook/src/cloudflare.test.ts new file mode 100644 index 0000000..0b4f7e4 --- /dev/null +++ b/packages/devhook/src/cloudflare.test.ts @@ -0,0 +1,41 @@ +/** + * Cloudflare server test suite. + * + * This file runs the shared test suite against the Cloudflare Worker implementation + * using wrangler's unstable_dev API for local testing. + * + * IMPORTANT: These tests require wrangler and its dependencies to work properly. + * They may be skipped in CI if wrangler isn't available. + * + * Run with: bun run test:cloudflare + * Or with explicit enable: ENABLE_CLOUDFLARE_TESTS=1 bun run test:cloudflare + * + * NOTE: WebSocket proxying tests are skipped in local mode because miniflare's + * WebSocket implementation has some differences from production Cloudflare. + * The HTTP tests are the primary concern for API compatibility. + */ + +import { describe, it, expect } from "vitest"; +import { createCloudflareServerFactory } from "./server/cloudflare.test-adapter"; +import { runSharedTests } from "./shared.test-suite"; + +const SERVER_SECRET = "test-server-secret"; + +// Check if we should skip tests (e.g., in CI without wrangler) +const SKIP_TESTS = process.env.SKIP_CLOUDFLARE_TESTS === "1"; + +if (SKIP_TESTS) { + describe("devhook cloudflare (skipped)", () => { + it("cloudflare tests are skipped - set SKIP_CLOUDFLARE_TESTS=0 to enable", () => { + expect(true).toBe(true); + }); + }); +} else { + // Run the shared test suite against the Cloudflare worker + // This ensures both local and Cloudflare servers pass the same tests + runSharedTests( + "cloudflare", + createCloudflareServerFactory(SERVER_SECRET), + SERVER_SECRET + ); +} diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts new file mode 100644 index 0000000..7ff91e3 --- /dev/null +++ b/packages/devhook/src/devhook.test.ts @@ -0,0 +1,92 @@ +/** + * Devhook test suite. + * + * This file runs the shared test suite against the local server implementation. + * The same tests are also run against the Cloudflare server in cloudflare.test.ts. + */ + +import { describe, it, expect } from "vitest"; +import { generateDevhookId, verifyDevhookId } from "./server/crypto"; +import { createLocalServerFactory } from "./server/local.test-adapter"; +import { runSharedTests } from "./shared.test-suite"; + +const SERVER_SECRET = "test-server-secret"; +const CLIENT_SECRET = "test-client-secret"; + +describe("devhook", () => { + // Crypto tests are standalone - they don't need a server + describe("crypto", () => { + it("should generate consistent devhook IDs", async () => { + const id1 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + const id2 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + expect(id1).toBe(id2); + expect(id1).toHaveLength(16); + expect(id1).toMatch(/^[0-9a-z]+$/); + }); + + it("should generate different IDs for different client secrets", async () => { + const id1 = await generateDevhookId("secret1", SERVER_SECRET); + const id2 = await generateDevhookId("secret2", SERVER_SECRET); + + expect(id1).not.toBe(id2); + }); + + it("should generate different IDs for different server secrets", async () => { + const id1 = await generateDevhookId(CLIENT_SECRET, "server1"); + const id2 = await generateDevhookId(CLIENT_SECRET, "server2"); + + expect(id1).not.toBe(id2); + }); + + it("should verify devhook IDs correctly", async () => { + const id = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + const isValid = await verifyDevhookId(id, CLIENT_SECRET, SERVER_SECRET); + expect(isValid).toBe(true); + + const isInvalid = await verifyDevhookId( + id, + "wrong-secret", + SERVER_SECRET + ); + expect(isInvalid).toBe(false); + }); + + it("should handle empty secrets", async () => { + const id = await generateDevhookId("", SERVER_SECRET); + expect(id).toHaveLength(16); + expect(id).toMatch(/^[0-9a-z]+$/); + }); + + it("should handle unicode secrets", async () => { + const id = await generateDevhookId("секрет🔐", SERVER_SECRET); + expect(id).toHaveLength(16); + expect(id).toMatch(/^[0-9a-z]+$/); + }); + + it("should use full base36 alphabet for maximum entropy", async () => { + const ids = new Set(); + const allChars = new Set(); + + for (let i = 0; i < 100; i++) { + const id = await generateDevhookId(`secret-${i}`, SERVER_SECRET); + ids.add(id); + for (const char of id) { + allChars.add(char); + } + } + + expect(ids.size).toBe(100); + const beyondHex = [...allChars].filter((c) => c >= "g" && c <= "z"); + expect(beyondHex.length).toBeGreaterThan(0); + }); + }); + + // Run shared tests against local server + runSharedTests( + "local", + createLocalServerFactory(SERVER_SECRET), + SERVER_SECRET + ); +}); diff --git a/packages/devhook/src/emitter.ts b/packages/devhook/src/emitter.ts new file mode 100644 index 0000000..b91d6da --- /dev/null +++ b/packages/devhook/src/emitter.ts @@ -0,0 +1,35 @@ +/** + * Simple event emitter for internal use. + */ +export interface Disposable { + dispose(): void; +} + +export interface Event { + (listener: (event: T) => void): Disposable; +} + +export class Emitter { + private listeners: ((event: T) => void)[] = []; + + public get event(): Event { + return (listener: (event: T) => void) => { + this.listeners.push(listener); + return { + dispose: () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }, + }; + }; + } + + public emit(event: T) { + for (let i = this.listeners.length - 1; i >= 0; i--) { + this.listeners[i]?.(event); + } + } + + public dispose() { + this.listeners = []; + } +} diff --git a/packages/devhook/src/index.ts b/packages/devhook/src/index.ts new file mode 100644 index 0000000..fe5898b --- /dev/null +++ b/packages/devhook/src/index.ts @@ -0,0 +1,71 @@ +/** + * @blink-sdk/devhook + * + * Expose local servers via a public URL. + * + * ## Client Usage + * + * ```ts + * import { DevhookClient } from "@blink-sdk/devhook"; + * + * const client = new DevhookClient({ + * serverUrl: "https://devhook.example.com", + * secret: "my-secret-key", + * onRequest: async (req) => { + * // Forward to local server + * const url = new URL(req.url); + * url.host = "localhost:3000"; + * return fetch(new Request(url.toString(), req)); + * }, + * onConnect: ({ url }) => { + * console.log(`Devhook available at: ${url}`); + * }, + * }); + * + * const disposable = client.connect(); + * // Later: disposable.dispose(); + * ``` + * + * ## Server Deployment + * + * The server can be deployed to Cloudflare Workers or run locally. + * See the `server/` directory for implementation details. + * + * ### Cloudflare Workers + * + * ```toml + * # wrangler.toml + * name = "devhook-server" + * main = "node_modules/@blink-sdk/devhook/dist/server/cloudflare.js" + * + * [vars] + * DEVHOOK_SECRET = "your-server-secret" + * DEVHOOK_BASE_URL = "https://devhook.example.com" + * DEVHOOK_MODE = "wildcard" # or "subpath" + * + * [[durable_objects.bindings]] + * name = "DEVHOOK_SESSION" + * class_name = "DevhookSession" + * ``` + * + * ### Local Testing + * + * ```ts + * import { createLocalServer } from "@blink-sdk/devhook/server/local"; + * + * const server = createLocalServer({ + * port: 8080, + * secret: "server-secret", + * baseUrl: "http://localhost:8080", + * mode: "subpath", + * }); + * ``` + */ + +export { + DevhookClient, + type DevhookClientOptions, + type WebSocketRequest, +} from "./client"; +export type { Disposable } from "./emitter"; +export type { ConnectionEstablished } from "./schema"; diff --git a/packages/devhook/src/schema.ts b/packages/devhook/src/schema.ts new file mode 100644 index 0000000..0e1de0b --- /dev/null +++ b/packages/devhook/src/schema.ts @@ -0,0 +1,105 @@ +/** + * Protocol schema for devhook proxy messages. + * + * The protocol uses a binary format over WebSocket with multiplexed streams. + * Each message has a 1-byte type prefix followed by the payload. + */ + +/** + * Message types sent from the server (Cloudflare Worker) to the client. + */ +export enum ServerMessageType { + /** Initial proxy request with method, URL, and headers */ + PROXY_INIT = 0x01, + /** Body data chunk for the proxy request */ + PROXY_BODY = 0x02, + /** WebSocket message to forward */ + PROXY_WEBSOCKET_MESSAGE = 0x03, + /** WebSocket close signal */ + PROXY_WEBSOCKET_CLOSE = 0x04, +} + +/** + * Message types sent from the client back to the server. + */ +export enum ClientMessageType { + /** Initial proxy response with status code and headers */ + PROXY_INIT = 0x01, + /** Body data chunk for the proxy response */ + PROXY_DATA = 0x02, + /** WebSocket message to forward */ + PROXY_WEBSOCKET_MESSAGE = 0x03, + /** WebSocket close signal */ + PROXY_WEBSOCKET_CLOSE = 0x04, +} + +/** + * Server-to-client: Initial proxy request. + */ +export interface ProxyInitRequest { + method: string; + url: string; + headers: Record; +} + +/** + * Client-to-server: Initial proxy response. + */ +export interface ProxyInitResponse { + status_code: number; + status_message: string; + headers: Record; + /** Set-Cookie headers must be sent separately to preserve multiple cookies */ + set_cookies?: string[]; +} + +/** + * WebSocket close payload. + */ +export interface WebSocketClosePayload { + code?: number; + reason?: string; +} + +/** + * Connection established message sent to client. + */ +export interface ConnectionEstablished { + /** The public URL for this devhook */ + url: string; + /** The devhook ID (subdomain or path prefix) */ + id: string; +} + +/** + * Create a WebSocket message payload with type prefix. + * First byte: 0x00 for text, 0x01 for binary. + */ +export function createWebSocketMessagePayload( + payload: string | Uint8Array | ArrayBuffer, + encoder: TextEncoder +): Uint8Array { + const isText = typeof payload === "string"; + if (typeof payload === "string") { + payload = encoder.encode(payload); + } + + const arr = new Uint8Array(1 + payload.byteLength); + arr[0] = isText ? 0x00 : 0x01; + arr.set(new Uint8Array(payload), 1); + return arr; +} + +/** + * Parse a WebSocket message payload. + * Returns string for text messages, Uint8Array for binary. + */ +export function parseWebSocketMessagePayload( + payload: Uint8Array, + decoder: TextDecoder +): string | Uint8Array { + if (payload[0] === 0x00) { + return decoder.decode(payload.subarray(1)); + } + return new Uint8Array(payload.subarray(1)); +} diff --git a/packages/devhook/src/server/cloudflare.test-adapter.ts b/packages/devhook/src/server/cloudflare.test-adapter.ts new file mode 100644 index 0000000..750ffdd --- /dev/null +++ b/packages/devhook/src/server/cloudflare.test-adapter.ts @@ -0,0 +1,79 @@ +/** + * Test adapter for the Cloudflare devhook server using wrangler's unstable_dev. + * + * Note: The unstable_dev API can be finicky. This adapter may need adjustments + * based on the wrangler version and local environment. + */ + +import { unstable_dev, type UnstableDevWorker } from "wrangler"; +import type { TestServer, TestServerFactory } from "../test-utils"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, "../.."); + +interface CloudflareTestServer extends TestServer { + worker: UnstableDevWorker; +} + +/** + * Create a Cloudflare Worker server for testing using wrangler's unstable_dev. + */ +export async function createCloudflareTestServer( + secret: string +): Promise { + // Use a specific port for the worker + const port = 9787 + Math.floor(Math.random() * 1000); + + // Use the wrangler.toml for proper Durable Object configuration + const worker = await unstable_dev(join(__dirname, "cloudflare.ts"), { + experimental: { + disableExperimentalWarning: true, + }, + // Use wrangler.toml for DO bindings but override vars + config: join(packageRoot, "wrangler.toml"), + vars: { + DEVHOOK_SECRET: secret, + DEVHOOK_BASE_URL: `http://localhost:${port}`, + DEVHOOK_MODE: "subpath", + }, + local: true, + persist: false, + port, + }); + + const url = `http://127.0.0.1:${port}`; + + // Wait for worker to be ready by polling health endpoint + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${url}/health`); + if (response.ok) { + break; + } + } catch { + // Worker not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return { + url, + secret, + worker, + close: async () => { + await worker.stop(); + }, + }; +} + +/** + * Factory for creating Cloudflare test servers. + */ +export const createCloudflareServerFactory = ( + secret: string +): TestServerFactory => { + return async () => createCloudflareTestServer(secret); +}; diff --git a/packages/devhook/src/server/cloudflare.ts b/packages/devhook/src/server/cloudflare.ts new file mode 100644 index 0000000..e71c33f --- /dev/null +++ b/packages/devhook/src/server/cloudflare.ts @@ -0,0 +1,181 @@ +/** + * Cloudflare Worker entry point for the devhook server. + * + * This worker handles: + * 1. Client connections at /api/devhook/connect + * 2. Proxy requests via wildcard subdomains (*.example.com) + * 3. Proxy requests via subpath routing (/devhook/:id/*) + */ + +import { generateDevhookId } from "./crypto"; +import type { DevhookSession, DevhookSessionEnv } from "./durable-object"; + +export interface Env extends DevhookSessionEnv { + DEVHOOK_SESSION: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Handle client connection requests + if (url.pathname === "/api/devhook/connect") { + return handleClientConnect(request, env); + } + + // Handle proxy requests + const devhookId = extractDevhookId(url, env); + if (devhookId) { + return handleProxyRequest(request, env, devhookId); + } + + // Health check endpoint + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "content-type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + error: "Not found", + message: "This endpoint does not exist.", + }), + { + status: 404, + headers: { "content-type": "application/json" }, + } + ); + }, +}; + +/** + * Handle a client connecting to establish a devhook. + */ +async function handleClientConnect( + request: Request, + env: Env +): Promise { + // Verify WebSocket upgrade + if (request.headers.get("upgrade") !== "websocket") { + return new Response( + JSON.stringify({ + error: "WebSocket required", + message: "This endpoint requires a WebSocket connection.", + }), + { + status: 426, + headers: { "content-type": "application/json" }, + } + ); + } + + // Get client secret from header + const clientSecret = request.headers.get("x-devhook-secret"); + if (!clientSecret) { + return new Response( + JSON.stringify({ + error: "Missing secret", + message: "The x-devhook-secret header is required.", + }), + { + status: 401, + headers: { "content-type": "application/json" }, + } + ); + } + + // Generate the devhook ID from the client secret + const devhookId = await generateDevhookId(clientSecret, env.DEVHOOK_SECRET); + + // Get or create the Durable Object for this session + const sessionId = env.DEVHOOK_SESSION.idFromName(devhookId); + const session = env.DEVHOOK_SESSION.get(sessionId); + + // Forward to the Durable Object with the devhook ID in a header + // The DO will handle initialization internally + const headers = new Headers(request.headers); + headers.set("x-devhook-id", devhookId); + + return session.fetch( + new Request(request.url, { + method: request.method, + headers, + body: request.body, + }) + ); +} + +/** + * Extract the devhook ID from the request URL. + * Supports both wildcard subdomain and subpath modes. + */ +function extractDevhookId(url: URL, env: Env): string | undefined { + const mode = env.DEVHOOK_MODE || "wildcard"; + const baseUrl = new URL(env.DEVHOOK_BASE_URL); + + if (mode === "subpath") { + // Subpath mode: /devhook/:id/* + // Base36 IDs: 16 characters of [0-9a-z] + const match = url.pathname.match(/^\/devhook\/([0-9a-z]{16})(\/.*)?$/); + if (match) { + return match[1]; + } + } else { + // Wildcard mode: :id.example.com + const baseHost = baseUrl.hostname; + if (url.hostname.endsWith(`.${baseHost}`) && url.hostname !== baseHost) { + const subdomain = url.hostname.slice(0, -(baseHost.length + 1)); + // Validate it looks like a devhook ID (16 base36 characters) + if (/^[0-9a-z]{16}$/.test(subdomain)) { + return subdomain; + } + } + } + + return undefined; +} + +/** + * Handle a proxy request to a devhook. + */ +async function handleProxyRequest( + request: Request, + env: Env, + devhookId: string +): Promise { + const sessionId = env.DEVHOOK_SESSION.idFromName(devhookId); + const session = env.DEVHOOK_SESSION.get( + sessionId + ) as unknown as DevhookSession; + + // Build the proxy URL + const url = new URL(request.url); + const mode = env.DEVHOOK_MODE || "wildcard"; + + let proxyPath: string; + if (mode === "subpath") { + // Remove the /devhook/:id prefix + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + + // Construct the full proxy URL (preserving query string) + const proxyUrl = new URL(proxyPath + url.search, url.origin); + + // Forward to the Durable Object with the proxy URL header + const headers = new Headers(request.headers); + headers.set("x-devhook-proxy-url", proxyUrl.toString()); + + return session.fetch( + new Request("https://devhook/proxy", { + method: request.method, + headers, + body: request.body, + }) + ); +} + +// Re-export the Durable Object for wrangler +export { DevhookSession } from "./durable-object"; diff --git a/packages/devhook/src/server/crypto.ts b/packages/devhook/src/server/crypto.ts new file mode 100644 index 0000000..524c57e --- /dev/null +++ b/packages/devhook/src/server/crypto.ts @@ -0,0 +1,122 @@ +/** + * Cryptographic utilities for devhook URL generation. + * + * Deterministically derives a uniform 16-character base36 string from: + * - a client secret (password), and + * - a server secret key for HMAC. + * + * Core idea: + * - HMAC-SHA-256 is used as a deterministic pseudorandom function (PRF). + * - We map the PRF output into [0, 36^16) using rejection sampling to avoid modulo bias. + * + * We use base36 encoding (a-z, 0-9) to maximize entropy per character. + * With 16 characters and 36 possible values each, we get: + * 36^16 ≈ 7.96 × 10^24 ≈ 2^82.7 possible IDs + */ + +/** Domain separation constant for devhook ID generation */ +const DOMAIN = "blink-devhook"; + +/** + * Convert 16 bytes to an unsigned 128-bit BigInt (big-endian). + */ +function bytesToBigInt128(bytes: Uint8Array, off: number): bigint { + let x = 0n; + for (let i = 0; i < 16; i++) { + x = (x << 8n) + BigInt(bytes[off + i]!); + } + return x; +} + +/** + * Generate a secure devhook ID from a client secret. + * Uses HMAC-SHA256 with the server secret, then converts to base36 using + * rejection sampling to ensure uniform distribution. + * + * @param clientSecret - The secret provided by the client (password) + * @param serverSecret - The server's secret key for signing + * @returns A 16-character base36 devhook ID (a-z, 0-9) + */ +export async function generateDevhookId( + clientSecret: string, + serverSecret: string +): Promise { + const enc = new TextEncoder(); + + // WebCrypto works with bytes; TextEncoder gives deterministic UTF-8 bytes. + const keyBytes = enc.encode(serverSecret); + + // Import the HMAC key into WebCrypto. + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + // We want a 16-character base36 string. That's exactly the integers in [0, 36^16). + const N = 36n ** 16n; // size of the output space + + // We'll draw 128-bit candidates from the PRF output and "reject" ones that would bias the mapping. + const TWO128 = 1n << 128n; + + // Rejection sampling threshold: + // limit is the largest multiple of N that is < 2^128. + // If we only accept x < limit, then x % N is perfectly uniform in [0, N). + const limit = (TWO128 / N) * N; + + // One HMAC-SHA-256 output is 32 bytes, which conveniently contains two independent + // 128-bit candidates (first 16 bytes and last 16 bytes). + // + // Rejection sampling very rarely rejects when N is close to 2^k, but we still implement + // the correct rejection loop to guarantee uniformity. + for (let ctr = 0; ctr < 1000; ctr++) { + // Build the message to MAC: + // - DOMAIN: separates different uses / versions + // - clientSecret: user input + // - ctr: deterministic retry stream + // + // The \0 separators avoid ambiguous concatenations like ("ab", "c") vs ("a", "bc"). + const msg = enc.encode(`${DOMAIN}\0${clientSecret}\0${ctr}`); + + // HMAC the message. WebCrypto returns an ArrayBuffer; wrap in Uint8Array for byte access. + const mac = new Uint8Array( + await crypto.subtle.sign("HMAC", cryptoKey, msg) + ); + + // Try two 128-bit candidates per MAC output. + for (const off of [0, 16]) { + const x = bytesToBigInt128(mac, off); + + // Reject values in the "tail" [limit, 2^128) because modulo would make some outputs + // slightly more likely than others (modulo bias). + if (x < limit) { + const y = x % N; // now y is uniform in [0, 36^16) + + // Convert to base36 and left-pad with '0' to ensure fixed length of 16 chars. + return y.toString(36).padStart(16, "0"); + } + } + } + + // If you ever hit this (extremely unlikely), increase the loop bound. + throw new Error("Unexpected: too many rejections; increase loop bound."); +} + +/** + * Verify that a devhook ID matches the expected value for a client secret. + * + * @param devhookId - The devhook ID to verify + * @param clientSecret - The client secret that should produce this ID + * @param serverSecret - The server's secret key + * @returns True if the ID is valid for this client secret + */ +export async function verifyDevhookId( + devhookId: string, + clientSecret: string, + serverSecret: string +): Promise { + const expected = await generateDevhookId(clientSecret, serverSecret); + return devhookId === expected; +} diff --git a/packages/devhook/src/server/durable-object.ts b/packages/devhook/src/server/durable-object.ts new file mode 100644 index 0000000..7edb902 --- /dev/null +++ b/packages/devhook/src/server/durable-object.ts @@ -0,0 +1,353 @@ +import { DurableObject } from "cloudflare:workers"; +import { Worker } from "./worker"; +import type { ConnectionEstablished } from "../schema"; + +type WebsocketState = + | { + type: "client"; + } + | { + type: "proxied"; + streamID: number; + }; + +interface WebSocket extends globalThis.WebSocket { + deserializeAttachment(): WebsocketState; + serializeAttachment(state: WebsocketState): void; +} + +export interface DevhookSessionEnv { + DEVHOOK_SECRET: string; + DEVHOOK_BASE_URL: string; + DEVHOOK_MODE: "wildcard" | "subpath"; +} + +/** + * Durable Object that manages a single devhook session. + * + * State that survives restarts: + * - id: The devhook ID (generated from client secret) + * - nextStreamID: For multiplexer continuity + * - clientSecret: To verify reconnections + */ +export class DevhookSession extends DurableObject { + private id?: string; + private clientSecret?: string; + private nextStreamID?: number; + private cachedWorker?: Worker; + + constructor(state: DurableObjectState, env: DevhookSessionEnv) { + super(state, env); + + // Restore persisted state + this.ctx.blockConcurrencyWhile(async () => { + this.id = await this.ctx.storage.get("id"); + this.clientSecret = await this.ctx.storage.get("clientSecret"); + this.nextStreamID = await this.ctx.storage.get("nextStreamID"); + }); + } + + /** + * Check if a client is currently connected. + */ + public isConnected(): boolean { + return this.ctx.getWebSockets("client").length > 0; + } + + /** + * Handle incoming requests. + */ + public override async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Proxy request (check BEFORE WebSocket upgrade since proxied WS also has upgrade header) + if ( + url.pathname === "/proxy" || + request.headers.has("x-devhook-proxy-url") + ) { + return this.handleProxyRequest(request); + } + + // Client connecting via WebSocket + if (request.headers.get("upgrade") === "websocket") { + // Initialize session from the headers if needed + const devhookId = request.headers.get("x-devhook-id"); + const clientSecret = request.headers.get("x-devhook-secret"); + + if (devhookId && clientSecret && !this.id) { + this.id = devhookId; + this.clientSecret = clientSecret; + await this.ctx.storage.put("id", devhookId); + await this.ctx.storage.put("clientSecret", clientSecret); + } + + return this.handleClientConnect(request); + } + + return new Response("Not found", { status: 404 }); + } + + /** + * Handle a client connecting via WebSocket. + */ + private async handleClientConnect(request: Request): Promise { + // Close any existing client connections + const existingClients = this.ctx.getWebSockets("client"); + for (const ws of existingClients) { + ws.close(1000, "A new client has connected."); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair) as [WebSocket, WebSocket]; + + server.serializeAttachment({ type: "client" }); + this.ctx.acceptWebSocket(server, ["client"]); + + // Send connection established message with the public URL + const publicUrl = this.getPublicUrl(); + const connectionInfo: ConnectionEstablished = { + url: publicUrl, + id: this.id!, + }; + + // Queue the message to be sent after the connection is established + this.ctx.waitUntil( + (async () => { + // Small delay to ensure WebSocket is ready + await new Promise((resolve) => setTimeout(resolve, 10)); + // Check if WebSocket is still open before sending (1 = OPEN) + if (server.readyState === 1) { + server.send(JSON.stringify(connectionInfo)); + } + })() + ); + + return new Response(null, { + status: 101, + webSocket: client, + }); + } + + /** + * Handle a proxy request from the edge. + */ + private async handleProxyRequest(request: Request): Promise { + if (!this.isConnected()) { + return new Response( + JSON.stringify({ + error: "No client connected", + message: + "The devhook client is not currently connected. Please ensure your local server is running.", + }), + { + status: 503, + headers: { "content-type": "application/json" }, + } + ); + } + + const proxyUrl = request.headers.get("x-devhook-proxy-url") ?? request.url; + const headers = new Headers(request.headers); + headers.delete("x-devhook-proxy-url"); + + const worker = this.getWorker(); + + try { + const response = await worker.proxy( + new Request(proxyUrl, { + headers, + method: request.method, + body: request.body, + signal: request.signal, + redirect: "manual", + }) + ); + + // Handle WebSocket upgrade + if (response.upgrade) { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair) as [WebSocket, WebSocket]; + server.serializeAttachment({ + type: "proxied", + streamID: response.stream, + }); + this.ctx.acceptWebSocket(server, [ + "proxied", + response.stream.toString(), + ]); + + return new Response(null, { + status: 101, + webSocket: client, + }); + } + + // Handle null body status codes + if ([101, 204, 205, 304].includes(response.status)) { + return new Response(null, { + status: response.status, + headers: response.headers, + statusText: response.statusText, + }); + } + + return new Response(response.body ?? null, { + status: response.status, + headers: response.headers, + statusText: response.statusText, + }); + } catch (err) { + return new Response( + JSON.stringify({ + error: "Proxy error", + message: err instanceof Error ? err.message : String(err), + }), + { + status: 502, + headers: { "content-type": "application/json" }, + } + ); + } + } + + /** + * Handle WebSocket messages. + */ + public override async webSocketMessage( + ws: WebSocket, + message: string | ArrayBuffer + ): Promise { + const state = ws.deserializeAttachment(); + const worker = this.getWorker(); + + switch (state.type) { + case "client": { + let bytes: Uint8Array; + if (typeof message === "string") { + // Node.js ws library may send binary as string in some workerd/miniflare environments + // Convert string to binary assuming Latin-1 encoding (each char is one byte) + bytes = new Uint8Array(message.length); + for (let i = 0; i < message.length; i++) { + bytes[i] = message.charCodeAt(i); + } + } else { + bytes = new Uint8Array(message); + } + worker.handleClientMessage(bytes); + break; + } + case "proxied": { + // Forward WebSocket message to client + worker.sendProxiedWebSocketMessage(state.streamID, message); + break; + } + } + } + + /** + * Handle WebSocket close. + */ + public override async webSocketClose( + ws: WebSocket, + code: number + ): Promise { + const state = ws.deserializeAttachment(); + + switch (state.type) { + case "client": { + // Client disconnected, close all proxied WebSockets + const proxied = this.ctx.getWebSockets("proxied"); + for (const proxyWs of proxied) { + try { + proxyWs.close(code, "Client disconnected"); + } catch { + // Ignore errors + } + } + break; + } + case "proxied": { + const worker = this.getWorker(); + worker.sendProxiedWebSocketClose(state.streamID, code); + // Close the server side of the WebSocketPair to complete the handshake + try { + ws.close(code, "Closed"); + } catch { + // Already closed + } + break; + } + } + } + + /** + * Handle WebSocket errors. + */ + public override async webSocketError( + _ws: WebSocket, + _error: unknown + ): Promise { + // Suppress errors to avoid noisy logs + } + + /** + * Get or create the Worker instance. + */ + private getWorker(): Worker { + if (!this.cachedWorker) { + this.cachedWorker = new Worker({ + initialNextStreamID: this.nextStreamID, + sendToClient: (data: Uint8Array) => { + const clients = this.ctx.getWebSockets("client"); + for (const client of clients) { + try { + client.send(data); + } catch { + // Ignore send errors + } + } + }, + }); + + // Persist stream ID changes + this.cachedWorker.onNextStreamIDChange((streamID: number) => { + this.nextStreamID = streamID; + this.ctx.waitUntil(this.ctx.storage.put("nextStreamID", streamID)); + }); + + // Handle WebSocket messages from the client + this.cachedWorker.onWebSocketMessage((event) => { + const [socket] = this.ctx.getWebSockets(event.stream.toString()); + if (socket) { + socket.send(event.message); + } + }); + + // Handle WebSocket close from the client + this.cachedWorker.onWebSocketClose((event) => { + const [socket] = this.ctx.getWebSockets(event.stream.toString()); + if (socket) { + socket.close(event.code, event.reason); + } + }); + } + return this.cachedWorker; + } + + /** + * Get the public URL for this devhook. + */ + private getPublicUrl(): string { + const baseUrl = this.env.DEVHOOK_BASE_URL; + const mode = this.env.DEVHOOK_MODE || "wildcard"; + + if (mode === "subpath") { + return `${baseUrl}/devhook/${this.id}`; + } else { + // Wildcard mode: insert ID as subdomain + const url = new URL(baseUrl); + url.hostname = `${this.id}.${url.hostname}`; + return url.toString().replace(/\/$/, ""); + } + } +} diff --git a/packages/devhook/src/server/local.test-adapter.ts b/packages/devhook/src/server/local.test-adapter.ts new file mode 100644 index 0000000..d64dabe --- /dev/null +++ b/packages/devhook/src/server/local.test-adapter.ts @@ -0,0 +1,34 @@ +/** + * Test adapter for the local devhook server. + */ + +import { createLocalServer } from "./local"; +import type { TestServer, TestServerFactory } from "../test-utils"; + +let portCounter = 17000; + +/** + * Create a local server for testing. + */ +export function createLocalTestServer(secret: string): TestServer { + const port = portCounter++; + const server = createLocalServer({ + port, + secret, + baseUrl: `http://localhost:${port}`, + mode: "subpath", + }); + + return { + url: `http://localhost:${port}`, + secret, + close: () => server.close(), + }; +} + +/** + * Factory for creating local test servers. + */ +export const createLocalServerFactory = (secret: string): TestServerFactory => { + return async () => createLocalTestServer(secret); +}; diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts new file mode 100644 index 0000000..c9050be --- /dev/null +++ b/packages/devhook/src/server/local.ts @@ -0,0 +1,495 @@ +/** + * Local server implementation for testing devhook. + * + * This provides the same functionality as the Cloudflare Worker + * but runs locally using Node.js. + */ + +import { createServer, type Server as HttpServer } from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +import { Worker } from "./worker"; +import { generateDevhookId } from "./crypto"; +import type { ConnectionEstablished } from "../schema"; + +export interface LocalServerOptions { + /** + * Port to listen on. + */ + port: number; + + /** + * Server secret for HMAC signing. + */ + secret: string; + + /** + * Base URL for generating public URLs. + * In wildcard mode, devhook IDs become subdomains. + * In subpath mode, devhook IDs become path prefixes. + */ + baseUrl: string; + + /** + * Routing mode. + * - "wildcard": Use subdomains (requires DNS setup) + * - "subpath": Use path prefixes (easier for local testing) + */ + mode?: "wildcard" | "subpath"; + + /** + * Called when the server starts. + */ + onReady?: (port: number) => void; + + /** + * Called when a client connects. + */ + onClientConnect?: (id: string) => void; + + /** + * Called when a client disconnects. + */ + onClientDisconnect?: (id: string) => void; +} + +interface Session { + id: string; + clientSecret: string; + ws: WebSocket | null; + worker: Worker | null; + proxiedWebSockets: Map; +} + +/** + * Create a local devhook server for testing. + * + * @example + * ```ts + * const server = createLocalServer({ + * port: 8080, + * secret: "server-secret", + * baseUrl: "http://localhost:8080", + * mode: "subpath", + * onReady: (port) => console.log(`Server running on port ${port}`), + * }); + * + * // Later: server.close(); + * ``` + */ +export function createLocalServer(opts: LocalServerOptions): { + server: HttpServer; + close: () => void; +} { + const sessions = new Map(); + const mode = opts.mode || "subpath"; + + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + + // Health check + if (url.pathname === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + // WebSocket connection handled separately + if (url.pathname === "/api/devhook/connect") { + // WebSocket upgrade is handled by the WebSocketServer + res.writeHead(426, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "WebSocket required", + message: "This endpoint requires a WebSocket connection.", + }) + ); + return; + } + + // Extract devhook ID + const devhookId = extractDevhookId( + url, + opts.baseUrl, + mode, + req.headers.host + ); + if (!devhookId) { + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Not found", + message: "This endpoint does not exist.", + }) + ); + return; + } + + // Find the session + const session = sessions.get(devhookId); + if (!session || !session.ws || session.ws.readyState !== WebSocket.OPEN) { + res.writeHead(503, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "No client connected", + message: + "The devhook client is not currently connected. Please ensure your local server is running.", + }) + ); + return; + } + + // Build proxy URL + let proxyPath: string; + if (mode === "subpath") { + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + const proxyUrl = new URL(proxyPath + url.search, url.origin); + + // Collect request body + const bodyChunks: Buffer[] = []; + for await (const chunk of req) { + bodyChunks.push(chunk); + } + const body = Buffer.concat(bodyChunks); + + // Build headers + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + headers[key] = Array.isArray(value) ? value.join(", ") : value; + } + } + + try { + // Create request for the worker + const proxyRequest = new Request(proxyUrl.toString(), { + method: req.method || "GET", + headers, + body: body.length > 0 ? body : undefined, + }); + + const worker = session.worker!; + const response = await worker.proxy(proxyRequest); + + // Handle WebSocket upgrade - this shouldn't happen for HTTP requests + // WebSocket upgrades are handled in the httpServer.on("upgrade") handler + if (response.upgrade) { + res.writeHead(400, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Bad request", + message: + "WebSocket upgrade requests must use the WebSocket protocol.", + }) + ); + return; + } + + // Write response headers + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + // Skip Set-Cookie - handled separately to preserve multiple cookies + if (key.toLowerCase() !== "set-cookie") { + responseHeaders[key] = value; + } + }); + + // Handle multiple Set-Cookie headers (Node.js requires array for multiple values) + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders["Set-Cookie"] = setCookies; + } + + res.writeHead(response.status, response.statusText, responseHeaders); + + // Stream response body + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + res.write(Buffer.from(value)); + } + } + } finally { + reader.releaseLock(); + } + } + + res.end(); + } catch (err) { + res.writeHead(502, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Proxy error", + message: err instanceof Error ? err.message : String(err), + }) + ); + } + }); + + const wss = new WebSocketServer({ noServer: true }); + + // WebSocket server for proxied connections (external -> local) + const proxyWss = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", async (req, socket, head) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + + // Handle devhook client connections + if (url.pathname === "/api/devhook/connect") { + // Get client secret + const clientSecret = req.headers["x-devhook-secret"] as string; + if (!clientSecret) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + // Generate devhook ID + const devhookId = await generateDevhookId(clientSecret, opts.secret); + + // Get or create session + let session = sessions.get(devhookId); + if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + // Close existing connection + session.ws.close(1000, "A new client has connected."); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + // Create worker for this session + const worker = new Worker({ + initialNextStreamID: session?.worker ? undefined : 1, + sendToClient: (data: Uint8Array) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }, + }); + + session = { + id: devhookId, + clientSecret, + ws, + worker, + proxiedWebSockets: session?.proxiedWebSockets ?? new Map(), + }; + sessions.set(devhookId, session); + + // Subscribe to WebSocket messages from the devhook client + worker.onWebSocketMessage((event) => { + const proxyWs = session!.proxiedWebSockets.get(event.stream); + if (proxyWs && proxyWs.readyState === WebSocket.OPEN) { + proxyWs.send(event.message); + } + }); + + // Subscribe to WebSocket close events from the devhook client + worker.onWebSocketClose((event) => { + const proxyWs = session!.proxiedWebSockets.get(event.stream); + if (proxyWs) { + proxyWs.close(event.code, event.reason); + session!.proxiedWebSockets.delete(event.stream); + } + }); + + // Send connection info + const publicUrl = getPublicUrl(devhookId, opts.baseUrl, mode); + const connectionInfo: ConnectionEstablished = { + url: publicUrl, + id: devhookId, + }; + ws.send(JSON.stringify(connectionInfo)); + + opts.onClientConnect?.(devhookId); + + ws.on("message", (data: Buffer) => { + worker.handleClientMessage(new Uint8Array(data)); + }); + + ws.on("close", () => { + if (sessions.get(devhookId)?.ws === ws) { + const s = sessions.get(devhookId)!; + // Close all proxied WebSockets when client disconnects + for (const proxyWs of s.proxiedWebSockets.values()) { + try { + proxyWs.close(1001, "Devhook client disconnected"); + } catch { + // Ignore close errors + } + } + s.proxiedWebSockets.clear(); + s.ws = null; + s.worker = null; + } + opts.onClientDisconnect?.(devhookId); + }); + + ws.on("error", () => { + // Ignore errors + }); + }); + return; + } + + // Handle proxied WebSocket connections (external -> devhook -> local) + const devhookId = extractDevhookId( + url, + opts.baseUrl, + mode, + req.headers.host + ); + if (!devhookId) { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); + socket.destroy(); + return; + } + + const session = sessions.get(devhookId); + if ( + !session || + !session.ws || + session.ws.readyState !== WebSocket.OPEN || + !session.worker + ) { + socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n"); + socket.destroy(); + return; + } + + // Build proxy URL (strip devhook prefix in subpath mode) + let proxyPath: string; + if (mode === "subpath") { + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + const proxyUrl = new URL(proxyPath + url.search, url.origin); + + // Build headers + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + headers[key] = Array.isArray(value) ? value.join(", ") : value; + } + } + + // Send the WebSocket upgrade request through the worker to the devhook client + const worker = session.worker; + const proxyRequest = new Request(proxyUrl.toString(), { + method: "GET", + headers, + }); + + try { + const response = await worker.proxy(proxyRequest); + + if (!response.upgrade) { + // The local server didn't accept the WebSocket upgrade + socket.write( + `HTTP/1.1 ${response.status} ${response.statusText}\r\n\r\n` + ); + socket.destroy(); + return; + } + + const streamID = response.stream; + + // Upgrade the external connection + proxyWss.handleUpgrade(req, socket, head, (externalWs) => { + // Store the proxied WebSocket + session.proxiedWebSockets.set(streamID, externalWs); + + // Forward messages from external WebSocket to devhook client + externalWs.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { + const payload = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : Array.isArray(data) + ? Buffer.concat(data) + : data; + worker.sendProxiedWebSocketMessage(streamID, payload); + }); + + // Handle close from external WebSocket + externalWs.on("close", (code, reason) => { + worker.sendProxiedWebSocketClose(streamID, code, reason.toString()); + session.proxiedWebSockets.delete(streamID); + }); + + // Handle errors from external WebSocket + externalWs.on("error", () => { + worker.sendProxiedWebSocketClose(streamID, 1011, "WebSocket error"); + session.proxiedWebSockets.delete(streamID); + }); + }); + } catch (err) { + socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + socket.destroy(); + } + }); + + httpServer.listen(opts.port, () => { + opts.onReady?.(opts.port); + }); + + return { + server: httpServer, + close: () => { + // Close all WebSocket connections + for (const session of sessions.values()) { + session.ws?.close(1000, "Server shutting down"); + for (const ws of session.proxiedWebSockets.values()) { + ws.close(1000, "Server shutting down"); + } + } + sessions.clear(); + wss.close(); + proxyWss.close(); + httpServer.close(); + }, + }; +} + +function extractDevhookId( + url: URL, + baseUrl: string, + mode: "wildcard" | "subpath", + host?: string +): string | undefined { + if (mode === "subpath") { + // Match devhook IDs that are 16 base36 characters [0-9a-z] + const match = url.pathname.match(/^\/devhook\/([0-9a-z]{16})(\/.*)?$/); + return match?.[1]; + } else { + // Wildcard mode + const baseHost = new URL(baseUrl).hostname; + if (host && host.endsWith(`.${baseHost}`)) { + const subdomain = host.slice(0, -(baseHost.length + 1)); + // Remove port if present + const id = subdomain.split(":")[0]; + if (id && /^[0-9a-z]{16}$/.test(id)) { + return id; + } + } + } + return undefined; +} + +function getPublicUrl( + id: string, + baseUrl: string, + mode: "wildcard" | "subpath" +): string { + if (mode === "subpath") { + return `${baseUrl}/devhook/${id}`; + } else { + const url = new URL(baseUrl); + url.hostname = `${id}.${url.hostname}`; + return url.toString().replace(/\/$/, ""); + } +} diff --git a/packages/devhook/src/server/worker.ts b/packages/devhook/src/server/worker.ts new file mode 100644 index 0000000..06c5e1b --- /dev/null +++ b/packages/devhook/src/server/worker.ts @@ -0,0 +1,260 @@ +import Multiplexer, { type Stream, FrameCodec } from "@blink-sdk/multiplexer"; +import { Emitter } from "../emitter"; +import { + ClientMessageType, + ServerMessageType, + createWebSocketMessagePayload, + parseWebSocketMessagePayload, + type ProxyInitRequest, + type ProxyInitResponse, + type WebSocketClosePayload, +} from "../schema"; + +export interface WorkerOptions { + sendToClient: (message: Uint8Array) => void; + initialNextStreamID?: number; +} + +export interface ProxyResponse { + stream: number; + status: number; + headers: Headers; + statusText: string; + upgrade: boolean; + body?: ReadableStream; +} + +/** + * Worker handles the server-side of the devhook protocol. + * It multiplexes proxy requests to the connected client. + */ +export class Worker { + private readonly _onWebSocketMessage = new Emitter<{ + stream: number; + message: Uint8Array | string; + }>(); + public readonly onWebSocketMessage = this._onWebSocketMessage.event; + + private readonly _onWebSocketClose = new Emitter<{ + stream: number; + code: number; + reason: string; + }>(); + public readonly onWebSocketClose = this._onWebSocketClose.event; + + private readonly multiplexer: Multiplexer; + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + + constructor(private readonly opts: WorkerOptions) { + this.multiplexer = new Multiplexer({ + send: (message: Uint8Array) => { + opts.sendToClient(message); + }, + isClient: true, + initialNextStreamID: opts.initialNextStreamID, + }); + this.multiplexer.onStream((stream: Stream) => { + this.bindStream(stream); + }); + } + + public get onNextStreamIDChange() { + return this.multiplexer.onNextStreamIDChange; + } + + /** + * Proxy an HTTP request to the connected client. + */ + public proxy(request: Request): Promise { + const stream = this.multiplexer.createStream(); + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + + let resolveResponse: (response: ProxyResponse) => void; + let rejectResponse: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolveResponse = resolve; + rejectResponse = reject; + }); + + request.signal?.addEventListener( + "abort", + () => { + rejectResponse(request.signal.reason); + }, + { once: true } + ); + + const body = new TransformStream(); + const writer = body.writable.getWriter(); + let writeQueue: Promise = Promise.resolve(); + + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ClientMessageType.PROXY_INIT: { + const parsed = JSON.parse( + this.decoder.decode(payload) + ) as ProxyInitResponse; + + const headers = new Headers(parsed.headers); + + // Restore multiple Set-Cookie headers + if (parsed.set_cookies) { + for (const cookie of parsed.set_cookies) { + headers.append("Set-Cookie", cookie); + } + } + + resolveResponse({ + status: parsed.status_code, + headers, + statusText: parsed.status_message, + body: body.readable, + stream: stream.id, + upgrade: parsed.status_code === 101, + }); + break; + } + case ClientMessageType.PROXY_DATA: { + writeQueue = writeQueue.then(() => writer.write(payload)); + break; + } + // Note: PROXY_WEBSOCKET_MESSAGE and PROXY_WEBSOCKET_CLOSE are handled + // by bindStream() which is called for WebSocket upgrades. We don't + // handle them here to avoid duplicate event emissions. + } + }); + + stream.onClose(() => { + writeQueue.finally(() => writer.close().catch(() => {})); + }); + + stream.onError((error: string) => { + rejectResponse(new Error(error)); + }); + + // Send the proxy request to the client + const proxyInit: ProxyInitRequest = { + headers, + method: request.method, + url: request.url, + }; + + stream.writeTyped( + ServerMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + + // Handle WebSocket upgrade + if (headers["upgrade"] === "websocket") { + this.bindStream(stream); + return promise; + } + + // Stream request body + if (request.body) { + request.body + .pipeTo( + new WritableStream({ + write: (chunk) => { + stream.writeTyped(ServerMessageType.PROXY_BODY, chunk); + }, + }) + ) + .then(() => { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + }) + .catch(() => { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + }); + } else { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + } + + return promise.catch((err) => { + stream.close(); + throw err; + }); + } + + /** + * Send a WebSocket message to the client. + */ + public sendProxiedWebSocketMessage( + streamID: number, + message: Uint8Array | string | ArrayBuffer + ) { + let stream = this.multiplexer.getStream(streamID); + if (!stream) { + stream = this.multiplexer.createStream(streamID); + this.bindStream(stream); + } + stream.writeTyped( + ServerMessageType.PROXY_WEBSOCKET_MESSAGE, + createWebSocketMessagePayload(message, this.encoder) + ); + } + + /** + * Send a WebSocket close to the client. + */ + public sendProxiedWebSocketClose( + streamID: number, + code?: number, + reason?: string + ) { + let stream = this.multiplexer.getStream(streamID); + if (!stream) { + stream = this.multiplexer.createStream(streamID); + this.bindStream(stream); + } + const payload: WebSocketClosePayload = { code, reason }; + stream.writeTyped( + ServerMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(payload)) + ); + stream.close(); + } + + /** + * Handle a message from the connected client. + */ + public handleClientMessage(message: Uint8Array): void { + this.multiplexer.handleMessage(message); + } + + private bindStream(stream: Stream): void { + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ClientMessageType.PROXY_WEBSOCKET_MESSAGE: { + this._onWebSocketMessage.emit({ + stream: stream.id, + message: parseWebSocketMessagePayload(payload, this.decoder), + }); + break; + } + case ClientMessageType.PROXY_WEBSOCKET_CLOSE: { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + this._onWebSocketClose.emit({ + stream: stream.id, + code: closePayload.code ?? 1000, + reason: closePayload.reason ?? "", + }); + break; + } + } + }); + } +} diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts new file mode 100644 index 0000000..53be8e3 --- /dev/null +++ b/packages/devhook/src/shared.test-suite.ts @@ -0,0 +1,2740 @@ +/** + * Shared test suite that runs against any devhook server implementation. + * + * This file exports test functions that can be called with different server factories + * to ensure both local and Cloudflare servers behave identically. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { DevhookClient } from "./client"; +import { + type TestServer, + type TestServerFactory, + getDevhookId, + getDevhookUrl, + getDevhookWsUrl, + delay, +} from "./test-utils"; + +export interface SharedTestOptions { + /** + * Skip WebSocket proxying tests. + * Useful for environments where WebSocket behavior differs (e.g., miniflare). + */ + skipWebSocketTests?: boolean; +} + +/** + * Run the shared test suite against a server implementation. + */ +export function runSharedTests( + serverName: string, + serverFactory: TestServerFactory, + serverSecret: string, + options: SharedTestOptions = {} +) { + const { skipWebSocketTests = false } = options; + describe(`${serverName} server`, () => { + let server: TestServer; + + beforeAll(async () => { + server = await serverFactory(); + await delay(100); // Give server time to start + }); + + afterAll(async () => { + await server?.close(); + }); + + describe("basic endpoints", () => { + it("should respond to health check", async () => { + const response = await fetch(`${server.url}/health`); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: string }; + expect(body.status).toBe("ok"); + }); + + it("should return 404 for unknown routes", async () => { + const response = await fetch(`${server.url}/unknown`); + expect(response.status).toBe(404); + }); + + it("should return 426 for non-WebSocket connect requests", async () => { + const response = await fetch(`${server.url}/api/devhook/connect`); + expect(response.status).toBe(426); + }); + }); + + describe("client-server integration", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should connect and receive public URL", async () => { + let connectedUrl: string | undefined; + let connectedId: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "test-client", + onRequest: async () => new Response("OK"), + onConnect: ({ url, id }) => { + connectedUrl = url; + connectedId = id; + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + + await delay(200); + + expect(connectedUrl).toBeDefined(); + expect(connectedId).toBeDefined(); + expect(connectedId).toHaveLength(16); + expect(connectedUrl).toContain(connectedId); + }); + + it("should proxy GET requests", async () => { + let receivedRequest: Request | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "get-test", + onRequest: async (req) => { + receivedRequest = req; + return new Response("GET response"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("get-test", serverSecret); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/data") + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("GET response"); + expect(receivedRequest?.method).toBe("GET"); + expect(new URL(receivedRequest!.url).pathname).toBe("/api/data"); + }); + + it("should proxy POST requests with JSON body", async () => { + let receivedBody: unknown; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "post-test", + onRequest: async (req) => { + receivedBody = await req.json(); + return new Response(JSON.stringify({ received: true }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("post-test", serverSecret); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/submit"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "test", value: 123 }), + } + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { received: boolean }; + expect(body.received).toBe(true); + expect(receivedBody).toEqual({ name: "test", value: 123 }); + }); + + it("should preserve query parameters", async () => { + let receivedUrl: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "query-test", + onRequest: async (req) => { + receivedUrl = req.url; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("query-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/search?q=test&page=1")); + + expect(receivedUrl).toBeDefined(); + const url = new URL(receivedUrl!); + expect(url.searchParams.get("q")).toBe("test"); + expect(url.searchParams.get("page")).toBe("1"); + }); + + it("should preserve request headers", async () => { + let receivedHeaders: Record = {}; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "headers-test", + onRequest: async (req) => { + req.headers.forEach((value, key) => { + receivedHeaders[key.toLowerCase()] = value; + }); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("headers-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "x-custom-header": "custom-value", + authorization: "Bearer token123", + }, + }); + + expect(receivedHeaders["x-custom-header"]).toBe("custom-value"); + expect(receivedHeaders["authorization"]).toBe("Bearer token123"); + }); + + it("should return response headers from client", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "resp-headers-test", + onRequest: async () => { + return new Response("OK", { + headers: { + "x-custom-response": "response-value", + "cache-control": "no-cache", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("resp-headers-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.headers.get("x-custom-response")).toBe( + "response-value" + ); + expect(response.headers.get("cache-control")).toBe("no-cache"); + }); + + it("should handle different HTTP status codes", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "status-test", + onRequest: async (req) => { + const url = new URL(req.url); + const status = parseInt(url.searchParams.get("status") || "200"); + return new Response(null, { status }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("status-test", serverSecret); + + const response201 = await fetch( + getDevhookUrl(server, devhookId, "/?status=201") + ); + expect(response201.status).toBe(201); + + const response404 = await fetch( + getDevhookUrl(server, devhookId, "/?status=404") + ); + expect(response404.status).toBe(404); + + const response500 = await fetch( + getDevhookUrl(server, devhookId, "/?status=500") + ); + expect(response500.status).toBe(500); + }); + + it("should return 503 when no client is connected", async () => { + const devhookId = await getDevhookId("no-client", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(503); + const body = (await response.json()) as { error: string }; + expect(body.error).toBeDefined(); + }); + + it("should handle client disconnection gracefully", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "disconnect-test", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await delay(200); + + const devhookId = await getDevhookId("disconnect-test", serverSecret); + + // First request should work + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response1.status).toBe(200); + + // Disconnect + disposable.dispose(); + await delay(100); + + // Second request should fail + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response2.status).toBe(503); + }); + + it("should handle reconnection with same secret", async () => { + const secret = "reconnect-test"; + + const client1 = new DevhookClient({ + serverUrl: server.url, + secret, + onRequest: async () => new Response("client1"), + }); + + const disposable1 = client1.connect(); + await delay(200); + + const devhookId = await getDevhookId(secret, serverSecret); + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response1.text()).toBe("client1"); + + // Disconnect first client + disposable1.dispose(); + await delay(100); + + // Connect second client with same secret + const client2 = new DevhookClient({ + serverUrl: server.url, + secret, + onRequest: async () => new Response("client2"), + }); + + const disposable2 = client2.connect(); + clientConnections.push(disposable2); + await delay(200); + + // Should get response from new client + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response2.text()).toBe("client2"); + }); + + it("should handle multiple concurrent clients with different secrets", async () => { + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "multi-1", + onRequest: async () => new Response("response1"), + }); + + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "multi-2", + onRequest: async () => new Response("response2"), + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await delay(200); + + const devhookId1 = await getDevhookId("multi-1", serverSecret); + const devhookId2 = await getDevhookId("multi-2", serverSecret); + + const [response1, response2] = await Promise.all([ + fetch(getDevhookUrl(server, devhookId1, "/")), + fetch(getDevhookUrl(server, devhookId2, "/")), + ]); + + expect(await response1.text()).toBe("response1"); + expect(await response2.text()).toBe("response2"); + }); + + it("should handle request errors gracefully", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "error-test", + onRequest: async () => { + throw new Error("Handler error"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("error-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + }); + }); + + describe.skipIf(skipWebSocketTests)("websocket proxying", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should proxy WebSocket connections", async () => { + const receivedMessages: string[] = []; + let localWsConnected = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + localWsConnected = true; + ws.on("message", (data) => { + receivedMessages.push(data.toString()); + ws.send(`echo: ${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + const externalMessages: string[] = []; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send("hello from external"); + }); + + externalWs.on("message", (data) => { + externalMessages.push(data.toString()); + if (externalMessages.length >= 1) { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(localWsConnected).toBe(true); + expect(receivedMessages).toContain("hello from external"); + expect(externalMessages).toContain("echo: hello from external"); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket close from external client", async () => { + let localWsClosed = false; + let closeCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + localWsClosed = true; + closeCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-close-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(1000, "Normal closure"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(localWsClosed).toBe(true); + expect(closeCode).toBe(1000); + + localWsServer.close(); + }); + + it("should handle multiple concurrent WebSocket connections to the same client", async () => { + const localConnections: Set = new Set(); + let connectionCounter = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + const connId = connectionCounter++; + localConnections.add(connId); + + ws.on("message", (data) => { + ws.send(`conn${connId}: ${data.toString()}`); + }); + + ws.on("close", () => { + localConnections.delete(connId); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-concurrent-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "ws-concurrent-test", + serverSecret + ); + + // Create 5 concurrent WebSocket connections + const numConnections = 5; + const externalWsConnections: InstanceType[] = []; + const receivedMessages: Map = new Map(); + + for (let i = 0; i < numConnections; i++) { + receivedMessages.set(i, []); + const ws = new WsClient( + getDevhookWsUrl(server, devhookId, `/ws${i}`) + ); + externalWsConnections.push(ws); + } + + // Wait for all connections to open + await Promise.all( + externalWsConnections.map( + (ws, i) => + new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + ws.on("message", (data) => { + receivedMessages.get(i)!.push(data.toString()); + }); + setTimeout( + () => reject(new Error(`Connection ${i} timeout`)), + 5000 + ); + }) + ) + ); + + expect(localConnections.size).toBe(numConnections); + + // Send messages from each connection + for (let i = 0; i < numConnections; i++) { + externalWsConnections[i]!.send(`hello from ws${i}`); + } + + await delay(200); + + // Verify each connection received exactly one response + for (let i = 0; i < numConnections; i++) { + expect(receivedMessages.get(i)!.length).toBe(1); + expect(receivedMessages.get(i)![0]).toContain(`hello from ws${i}`); + } + + // Close all connections + for (const ws of externalWsConnections) { + ws.close(); + } + + await delay(100); + localWsServer.close(); + }); + + it("should handle WebSocket connections from multiple devhook clients simultaneously", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + + const localWsServer1 = new WebSocketServer({ port: 0 }); + const localWsPort1 = (localWsServer1.address() as { port: number }) + .port; + const localWsServer2 = new WebSocketServer({ port: 0 }); + const localWsPort2 = (localWsServer2.address() as { port: number }) + .port; + + const messages1: string[] = []; + const messages2: string[] = []; + + localWsServer1.on("connection", (ws) => { + ws.on("message", (data) => { + messages1.push(data.toString()); + ws.send(`server1: ${data.toString()}`); + }); + }); + + localWsServer2.on("connection", (ws) => { + ws.on("message", (data) => { + messages2.push(data.toString()); + ws.send(`server2: ${data.toString()}`); + }); + }); + + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "ws-multi-1", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort1}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort1}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "ws-multi-2", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort2}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort2}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await delay(200); + + const devhookId1 = await getDevhookId("ws-multi-1", serverSecret); + const devhookId2 = await getDevhookId("ws-multi-2", serverSecret); + + const externalWs1 = new WsClient( + getDevhookWsUrl(server, devhookId1, "/ws") + ); + const externalWs2 = new WsClient( + getDevhookWsUrl(server, devhookId2, "/ws") + ); + + const received1: string[] = []; + const received2: string[] = []; + + await Promise.all([ + new Promise((resolve, reject) => { + externalWs1.on("open", resolve); + externalWs1.on("error", reject); + externalWs1.on("message", (data) => + received1.push(data.toString()) + ); + setTimeout(() => reject(new Error("Timeout ws1")), 5000); + }), + new Promise((resolve, reject) => { + externalWs2.on("open", resolve); + externalWs2.on("error", reject); + externalWs2.on("message", (data) => + received2.push(data.toString()) + ); + setTimeout(() => reject(new Error("Timeout ws2")), 5000); + }), + ]); + + externalWs1.send("message to client 1"); + externalWs2.send("message to client 2"); + + await delay(200); + + // Verify messages were routed correctly + expect(messages1).toContain("message to client 1"); + expect(messages2).toContain("message to client 2"); + expect(messages1).not.toContain("message to client 2"); + expect(messages2).not.toContain("message to client 1"); + + // Verify exactly one response from each server + expect(received1.length).toBe(1); + expect(received1[0]).toContain("server1:"); + expect(received2.length).toBe(1); + expect(received2[0]).toContain("server2:"); + + externalWs1.close(); + externalWs2.close(); + await delay(100); + localWsServer1.close(); + localWsServer2.close(); + }); + + it("should isolate WebSocket connections - closing one doesn't affect others", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => ws.send(data)); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-isolate-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-isolate-test", serverSecret); + + const ws1 = new WsClient(getDevhookWsUrl(server, devhookId, "/a")); + const ws2 = new WsClient(getDevhookWsUrl(server, devhookId, "/b")); + const ws3 = new WsClient(getDevhookWsUrl(server, devhookId, "/c")); + + const received2: string[] = []; + const received3: string[] = []; + + await Promise.all([ + new Promise((resolve, reject) => { + ws1.on("open", resolve); + ws1.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws2.on("open", resolve); + ws2.on("error", reject); + ws2.on("message", (data) => received2.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws3.on("open", resolve); + ws3.on("error", reject); + ws3.on("message", (data) => received3.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + ]); + + // Close ws1 + ws1.close(); + await delay(200); + + // ws2 and ws3 should still work + expect(ws2.readyState).toBe(WsClient.OPEN); + expect(ws3.readyState).toBe(WsClient.OPEN); + + // Send messages on remaining connections + ws2.send("still alive 2"); + ws3.send("still alive 3"); + + await delay(200); + + expect(received2).toContain("still alive 2"); + expect(received3).toContain("still alive 3"); + + ws2.close(); + ws3.close(); + await delay(100); + localWsServer.close(); + }); + + // Note: miniflare/wrangler dev can be slow with WebSocket close propagation + // See: https://github.com/cloudflare/workers-sdk/issues/10307 + it( + "should close proxied WebSockets when devhook client disconnects", + { timeout: 30000 }, + async () => { + let externalWsClosed = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-disconnect-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + // Don't add to clientConnections - we'll manually dispose + await delay(200); + + const devhookId = await getDevhookId( + "ws-disconnect-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + resolve(); + }); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout connecting")), 5000); + }); + + // Disconnect the devhook client and wait for external WS to close + await new Promise((resolve, reject) => { + externalWs.on("close", () => { + externalWsClosed = true; + resolve(); + }); + + disposable.dispose(); + + // Longer timeout for miniflare's slow WebSocket close handling + setTimeout(() => { + reject( + new Error( + "External WS did not close after devhook client disconnect" + ) + ); + }, 20000); + }); + + expect(externalWsClosed).toBe(true); + + localWsServer.close(); + } + ); + + it("should return 503 when no client is connected for WebSocket", async () => { + const { WebSocket: WsClient } = await import("ws"); + const devhookId = await getDevhookId("nonexistent-ws", serverSecret); + + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve) => { + externalWs.on("error", () => { + resolve(); + }); + + externalWs.on("open", () => { + externalWs.close(); + resolve(); + }); + + setTimeout(resolve, 1000); + }); + + expect(externalWs.readyState).not.toBe(WsClient.OPEN); + }); + }); + + describe("multi-value headers", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should preserve multiple Set-Cookie headers", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "multi-cookie-test", + onRequest: async () => { + const headers = new Headers(); + headers.append("Set-Cookie", "a=1; Path=/"); + headers.append("Set-Cookie", "b=2; Path=/"); + headers.append("Set-Cookie", "c=3; Path=/"); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("multi-cookie-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + + // Get all Set-Cookie headers - this will fail because Record loses duplicates + const setCookieHeaders = response.headers.getSetCookie(); + expect(setCookieHeaders).toHaveLength(3); + expect(setCookieHeaders).toContain("a=1; Path=/"); + expect(setCookieHeaders).toContain("b=2; Path=/"); + expect(setCookieHeaders).toContain("c=3; Path=/"); + }); + + it("should handle Set-Cookie with comma in Expires date", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "cookie-expires-test", + onRequest: async () => { + const headers = new Headers(); + // Expires date contains a comma - if joined with ", " this breaks + headers.append( + "Set-Cookie", + "session=abc123; Expires=Thu, 01 Jan 2026 00:00:00 GMT; Path=/" + ); + headers.append( + "Set-Cookie", + "user=xyz; Expires=Fri, 02 Jan 2026 00:00:00 GMT; Path=/" + ); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "cookie-expires-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + + const setCookieHeaders = response.headers.getSetCookie(); + expect(setCookieHeaders).toHaveLength(2); + // Each cookie should be intact with its Expires date + expect( + setCookieHeaders.some( + (c) => + c.includes("session=abc123") && c.includes("Thu, 01 Jan 2026") + ) + ).toBe(true); + expect( + setCookieHeaders.some( + (c) => c.includes("user=xyz") && c.includes("Fri, 02 Jan 2026") + ) + ).toBe(true); + }); + + it("should preserve Set-Cookie with all attributes", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "cookie-attrs-test", + onRequest: async () => { + return new Response("OK", { + headers: { + "Set-Cookie": + "session=abc; Path=/app; Domain=example.com; Secure; HttpOnly; SameSite=Strict; Max-Age=3600", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("cookie-attrs-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const cookie = response.headers.get("set-cookie"); + expect(cookie).toContain("session=abc"); + expect(cookie).toContain("Path=/app"); + expect(cookie).toContain("Domain=example.com"); + expect(cookie).toContain("Secure"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + expect(cookie).toContain("Max-Age=3600"); + }); + + it("should handle multiple values for headers that can be combined", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "vary-header-test", + onRequest: async () => { + const headers = new Headers(); + headers.append("Vary", "Accept"); + headers.append("Vary", "Accept-Encoding"); + headers.append("Cache-Control", "no-cache"); + headers.append("Cache-Control", "no-store"); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("vary-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + // Vary headers can be combined with commas + const vary = response.headers.get("vary"); + expect(vary).toContain("Accept"); + expect(vary).toContain("Accept-Encoding"); + }); + }); + + describe("cookie handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should preserve multiple cookies in request Cookie header", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "multi-req-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "multi-req-cookie-test", + serverSecret + ); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + Cookie: "a=1; b=2; c=3", + }, + }); + + expect(receivedCookies).toBe("a=1; b=2; c=3"); + }); + + it("should handle cookies with URL-encoded special characters", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "encoded-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "encoded-cookie-test", + serverSecret + ); + // URL-encoded value with special chars: hello=world; foo=bar + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + Cookie: "data=hello%3Dworld%3B%20foo%3Dbar", + }, + }); + + expect(receivedCookies).toBe("data=hello%3Dworld%3B%20foo%3Dbar"); + }); + + it("should handle long cookie values", async () => { + let receivedCookies: string | null = null; + const longValue = "x".repeat(4000); // Near 4KB limit + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "long-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("long-cookie-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + Cookie: `longcookie=${longValue}`, + }, + }); + + expect(receivedCookies).toBe(`longcookie=${longValue}`); + }); + + it("should handle empty cookie value", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-cookie-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + Cookie: "empty=", + }, + }); + + expect(receivedCookies).toBe("empty="); + }); + + it("should handle cookies with unicode characters (URL-encoded)", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "unicode-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "unicode-cookie-test", + serverSecret + ); + // URL-encoded "值" (Chinese character for "value") + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + Cookie: "name=%E5%80%BC", + }, + }); + + expect(receivedCookies).toBe("name=%E5%80%BC"); + }); + }); + + describe("header edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle empty header value", async () => { + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-empty"); + return new Response("OK", { + headers: { "x-empty-response": "" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-empty": "" }, + }); + + expect(response.status).toBe(200); + // Empty headers may be preserved or stripped depending on implementation + expect(receivedHeader === "" || receivedHeader === null).toBe(true); + }); + + it("should handle very long header values", async () => { + const longValue = "x".repeat(8000); + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "long-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-long"); + return new Response("OK", { + headers: { "x-long-response": longValue }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("long-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-long": longValue }, + }); + + expect(response.status).toBe(200); + expect(receivedHeader).toBe(longValue); + expect(response.headers.get("x-long-response")).toBe(longValue); + }); + + it("should handle many headers", async () => { + const numHeaders = 100; + const sentHeaders: Record = {}; + for (let i = 0; i < numHeaders; i++) { + sentHeaders[`x-header-${i}`] = `value-${i}`; + } + + let receivedCount = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "many-headers-test", + onRequest: async (req) => { + for (let i = 0; i < numHeaders; i++) { + if (req.headers.get(`x-header-${i}`) === `value-${i}`) { + receivedCount++; + } + } + return new Response("OK", { headers: sentHeaders }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("many-headers-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: sentHeaders, + }); + + expect(response.status).toBe(200); + expect(receivedCount).toBe(numHeaders); + + // Check response headers + for (let i = 0; i < numHeaders; i++) { + expect(response.headers.get(`x-header-${i}`)).toBe(`value-${i}`); + } + }); + + it("should preserve header value case", async () => { + let receivedValue: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "header-case-test", + onRequest: async (req) => { + receivedValue = req.headers.get("x-mixed-case"); + return new Response("OK", { + headers: { "X-Response-Mixed": "MixedCaseValue" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("header-case-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "X-Mixed-Case": "MixedCaseValue" }, + }); + + expect(response.status).toBe(200); + expect(receivedValue).toBe("MixedCaseValue"); + // Header names are case-insensitive, but values should be preserved + expect(response.headers.get("x-response-mixed")).toBe("MixedCaseValue"); + }); + + it("should preserve Content-Type with charset", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "content-type-charset-test", + onRequest: async () => { + return new Response('{"test": true}', { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "content-type-charset-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const contentType = response.headers.get("content-type"); + expect(contentType).toContain("application/json"); + expect(contentType).toContain("charset=utf-8"); + }); + + it("should preserve Accept header with quality values", async () => { + let receivedAccept: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "accept-quality-test", + onRequest: async (req) => { + receivedAccept = req.headers.get("accept"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "accept-quality-test", + serverSecret + ); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { Accept: "text/html, application/json;q=0.9, */*;q=0.8" }, + }); + + expect(receivedAccept).toBe( + "text/html, application/json;q=0.9, */*;q=0.8" + ); + }); + + it("should handle headers with leading/trailing whitespace in values", async () => { + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "whitespace-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-whitespace"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "whitespace-header-test", + serverSecret + ); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-whitespace": " value with spaces " }, + }); + + // HTTP spec says leading/trailing whitespace should be trimmed + // but the exact behavior depends on implementation + expect(receivedHeader?.includes("value with spaces")).toBe(true); + }); + }); + + describe.skipIf(skipWebSocketTests)("websocket edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle text messages with UTF-8 multi-byte characters", async () => { + const testMessage = "Hello 世界 🌍 مرحبا"; + let receivedOnServer: string | undefined; + let receivedOnClient: string | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + receivedOnServer = data.toString(); + ws.send(testMessage); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-utf8-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-utf8-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(testMessage); + }); + + externalWs.on("message", (data) => { + receivedOnClient = data.toString(); + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedOnServer).toBe(testMessage); + expect(receivedOnClient).toBe(testMessage); + + externalWs.close(); + localWsServer.close(); + }); + + it( + "should handle large binary messages", + { timeout: 30000 }, + async () => { + // Use 64KB - a reasonable size that should work across implementations + const largeData = new Uint8Array(64 * 1024); + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256; + } + + let receivedSize = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + const buf = data as Buffer; + receivedSize = buf.length; + ws.send(buf); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-large-binary-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "ws-large-binary-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + let echoedSize = 0; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(largeData); + }); + + externalWs.on("message", (data) => { + echoedSize = (data as Buffer).length; + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 25000); + }); + + expect(receivedSize).toBe(64 * 1024); + expect(echoedSize).toBe(64 * 1024); + + externalWs.close(); + localWsServer.close(); + } + ); + + it("should handle empty WebSocket messages", async () => { + let receivedEmpty = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + if (data.toString() === "") { + receivedEmpty = true; + ws.send(""); + } + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-empty-msg-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-empty-msg-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + let receivedEmptyEcho = false; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(""); + }); + + externalWs.on("message", (data) => { + if (data.toString() === "") { + receivedEmptyEcho = true; + } + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedEmpty).toBe(true); + expect(receivedEmptyEcho).toBe(true); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle rapid sequential messages", async () => { + const messageCount = 50; // Reduced count for reliability + const receivedMessages: Set = new Set(); + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(`echo:${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-rapid-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-rapid-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + for (let i = 0; i < messageCount; i++) { + externalWs.send(`msg-${i}`); + } + }); + + externalWs.on("message", (data) => { + receivedMessages.add(data.toString()); + if (receivedMessages.size >= messageCount) { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 10000); + }); + + expect(receivedMessages.size).toBe(messageCount); + // Verify all messages were received + for (let i = 0; i < messageCount; i++) { + expect(receivedMessages.has(`echo:msg-${i}`)).toBe(true); + } + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket close code 3000 (registered)", async () => { + let receivedCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + receivedCloseCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-3000-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "ws-close-3000-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(3000, "Custom registered close"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedCloseCode).toBe(3000); + + localWsServer.close(); + }); + + it("should handle WebSocket close code 4000 (private use)", async () => { + let receivedCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + receivedCloseCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-4000-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "ws-close-4000-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(4000, "Private use close"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedCloseCode).toBe(4000); + + localWsServer.close(); + }); + + it( + "should handle server-initiated WebSocket close", + { timeout: 15000 }, + async () => { + let clientReceivedClose = false; + let clientCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws) => { + // Server initiates close after connection + setTimeout(() => { + ws.close(1000, "Server closing"); + }, 100); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-server-close-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "ws-server-close-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("close", (code) => { + clientReceivedClose = true; + clientCloseCode = code; + resolve(); + }); + + externalWs.on("error", reject); + // Miniflare can be slow with WebSocket close propagation + setTimeout(() => reject(new Error("Timeout")), 12000); + }); + + expect(clientReceivedClose).toBe(true); + expect(clientCloseCode).toBe(1000); + + localWsServer.close(); + } + ); + + it("should handle multiple WebSocket message exchanges", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + let serverMessageCount = 0; + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + serverMessageCount++; + ws.send(`reply:${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-exchange-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-exchange-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + let clientMessageCount = 0; + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Send first message + externalWs.send("hello"); + }); + + externalWs.on("message", (data) => { + clientMessageCount++; + const msg = data.toString(); + + // After receiving reply to first message, send second + if (msg === "reply:hello") { + externalWs.send("world"); + } else if (msg === "reply:world") { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + // Verify bidirectional communication worked with exact message counts + expect(serverMessageCount).toBe(2); + expect(clientMessageCount).toBe(2); + + externalWs.close(); + localWsServer.close(); + }); + + it( + "should handle WebSocket with query parameters", + { timeout: 10000 }, + async () => { + let receivedUrl: string | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws, req) => { + receivedUrl = req.url; + ws.send("connected"); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-query-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-query-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws?token=abc123&user=test") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Give some time for the message to arrive + setTimeout(() => { + if (receivedUrl) { + resolve(); + } + }, 500); + }); + + externalWs.on("message", () => { + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 8000); + }); + + expect(receivedUrl).toContain("token=abc123"); + expect(receivedUrl).toContain("user=test"); + + externalWs.close(); + localWsServer.close(); + } + ); + }); + + describe("request/response body edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle empty body with Content-Length: 0", async () => { + let receivedBody: string | undefined; + let receivedContentLength: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-body-test", + onRequest: async (req) => { + receivedContentLength = req.headers.get("content-length"); + receivedBody = await req.text(); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Length": "0" }, + body: "", + }); + + expect(response.status).toBe(200); + expect(receivedBody).toBe(""); + }); + + it("should handle large request body", { timeout: 30000 }, async () => { + const largeBody = "x".repeat(5 * 1024 * 1024); // 5MB + let receivedLength = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "large-req-body-test", + onRequest: async (req) => { + const body = await req.text(); + receivedLength = body.length; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "large-req-body-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + body: largeBody, + }); + + expect(response.status).toBe(200); + expect(receivedLength).toBe(5 * 1024 * 1024); + }); + + it("should handle large response body", { timeout: 30000 }, async () => { + const largeBody = "y".repeat(5 * 1024 * 1024); // 5MB + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "large-resp-body-test", + onRequest: async () => { + return new Response(largeBody); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "large-resp-body-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body.length).toBe(5 * 1024 * 1024); + }); + + it("should handle binary request/response bodies", async () => { + // PNG-like binary data with null bytes + const binaryData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + let receivedBinary: Uint8Array | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "binary-body-test", + onRequest: async (req) => { + const buffer = await req.arrayBuffer(); + receivedBinary = new Uint8Array(buffer); + return new Response(binaryData, { + headers: { "Content-Type": "application/octet-stream" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("binary-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: binaryData, + }); + + expect(response.status).toBe(200); + expect(receivedBinary).toEqual(binaryData); + + const responseBuffer = await response.arrayBuffer(); + expect(new Uint8Array(responseBuffer)).toEqual(binaryData); + }); + + it("should handle body with null bytes", async () => { + const dataWithNulls = new Uint8Array([ + 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, + ]); + let receivedData: Uint8Array | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "null-bytes-test", + onRequest: async (req) => { + const buffer = await req.arrayBuffer(); + receivedData = new Uint8Array(buffer); + return new Response(dataWithNulls); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("null-bytes-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + body: dataWithNulls, + }); + + expect(response.status).toBe(200); + expect(receivedData).toEqual(dataWithNulls); + + const responseBuffer = await response.arrayBuffer(); + expect(new Uint8Array(responseBuffer)).toEqual(dataWithNulls); + }); + + it("should handle JSON with unicode characters", async () => { + const jsonData = { name: "日本語", emoji: "🎉", arabic: "مرحبا" }; + let receivedJson: unknown; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "unicode-json-test", + onRequest: async (req) => { + receivedJson = await req.json(); + return new Response(JSON.stringify(jsonData), { + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("unicode-json-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(jsonData), + }); + + expect(response.status).toBe(200); + expect(receivedJson).toEqual(jsonData); + + const responseJson = await response.json(); + expect(responseJson).toEqual(jsonData); + }); + + it("should handle URL-encoded form data", async () => { + let receivedBody: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "form-data-test", + onRequest: async (req) => { + receivedBody = await req.text(); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("form-data-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "name=test&value=hello%20world&special=%26%3D%3F", + }); + + expect(response.status).toBe(200); + expect(receivedBody).toBe( + "name=test&value=hello%20world&special=%26%3D%3F" + ); + }); + }); + + describe("connection edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should start processing requests before client disconnects", async () => { + let requestStarted = false; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "slow-request-test", + onRequest: async () => { + requestStarted = true; + // Short delay to verify request started + await delay(100); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("slow-request-test", serverSecret); + + // Make a request + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(requestStarted).toBe(true); + expect(response.status).toBe(200); + }); + + it("should handle rapid reconnect cycles", async () => { + const cycles = 5; + const devhookId = await getDevhookId( + "rapid-reconnect-test", + serverSecret + ); + + for (let i = 0; i < cycles; i++) { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "rapid-reconnect-test", + onRequest: async () => new Response(`cycle-${i}`), + }); + + const disposable = client.connect(); + await delay(200); + + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response.status).toBe(200); + expect(await response.text()).toBe(`cycle-${i}`); + + disposable.dispose(); + await delay(50); + } + }); + + it( + "should handle many concurrent requests", + { timeout: 30000 }, + async () => { + const numRequests = 50; + let requestCount = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "concurrent-test", + onRequest: async (req) => { + requestCount++; + const url = new URL(req.url); + return new Response(`request-${url.searchParams.get("n")}`); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("concurrent-test", serverSecret); + + const promises = Array.from({ length: numRequests }, (_, i) => + fetch(getDevhookUrl(server, devhookId, `/?n=${i}`)) + ); + + const responses = await Promise.all(promises); + + for (let i = 0; i < numRequests; i++) { + expect(responses[i]!.status).toBe(200); + } + + expect(requestCount).toBe(numRequests); + } + ); + + it("should return 503 immediately after client disconnect", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "immediate-503-test", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await delay(200); + + const devhookId = await getDevhookId( + "immediate-503-test", + serverSecret + ); + + // Verify client works + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response1.status).toBe(200); + + // Disconnect + disposable.dispose(); + + // Immediate request should fail + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response2.status).toBe(503); + }); + + it( + "should handle new client connection with same secret", + { timeout: 10000 }, + async () => { + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client1"), + }); + + const disposable1 = client1.connect(); + await delay(300); + + const devhookId = await getDevhookId( + "replace-client-test", + serverSecret + ); + + // Verify client1 works + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response1.text()).toBe("client1"); + + // Disconnect client1 first + disposable1.dispose(); + await delay(100); + + // Connect client2 with same secret + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client2"), + }); + + const disposable2 = client2.connect(); + clientConnections.push(disposable2); + await delay(300); + + // Requests should now go to client2 + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response2.text()).toBe("client2"); + } + ); + }); + + describe("error handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should return 502 for handler errors", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "error-message-test", + onRequest: async () => { + throw new Error("Specific error message"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "error-message-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + // Error message format varies - just verify we got a 502 + const body = await response.text(); + expect(body.length).toBeGreaterThan(0); + }); + + it("should handle handler that returns rejected promise", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "rejected-promise-test", + onRequest: async () => { + return Promise.reject(new Error("Async rejection")); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "rejected-promise-test", + serverSecret + ); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + }); + + it("should handle various HTTP status codes correctly", async () => { + const statusCodes = [ + 200, 201, 204, 301, 302, 400, 401, 403, 404, 500, 502, 503, + ]; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "status-codes-test", + onRequest: async (req) => { + const url = new URL(req.url); + const status = parseInt(url.searchParams.get("status") || "200"); + // 204 should have no body + if (status === 204) { + return new Response(null, { status }); + } + return new Response(`Status: ${status}`, { status }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("status-codes-test", serverSecret); + + for (const status of statusCodes) { + const response = await fetch( + getDevhookUrl(server, devhookId, `/?status=${status}`) + ); + expect(response.status).toBe(status); + } + }); + }); + + describe("URL handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle path with URL-encoded special characters", async () => { + let receivedPath: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "encoded-path-test", + onRequest: async (req) => { + receivedPath = new URL(req.url).pathname; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("encoded-path-test", serverSecret); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/users/name%20with%20spaces") + ); + + expect(response.status).toBe(200); + // Path should be decoded or preserved depending on implementation + expect(receivedPath).toMatch(/name(%20| )with(%20| )spaces/); + }); + + it("should handle query string with special characters", async () => { + let receivedQuery: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "special-query-test", + onRequest: async (req) => { + receivedQuery = new URL(req.url).search; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId( + "special-query-test", + serverSecret + ); + // Encoded: & = ? in values + const response = await fetch( + getDevhookUrl( + server, + devhookId, + "/?search=hello%26world&name=foo%3Dbar" + ) + ); + + expect(response.status).toBe(200); + expect(receivedQuery).toContain("search=hello%26world"); + expect(receivedQuery).toContain("name=foo%3Dbar"); + }); + + it("should handle double slashes in path", async () => { + let receivedPath: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "double-slash-test", + onRequest: async (req) => { + receivedPath = new URL(req.url).pathname; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("double-slash-test", serverSecret); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api//data///test") + ); + + expect(response.status).toBe(200); + // Browsers/fetch may normalize slashes, but we should handle it + expect(receivedPath).toBeDefined(); + }); + }); + + describe("HTTP methods", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle all standard HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + const receivedMethods: string[] = []; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "http-methods-test", + onRequest: async (req) => { + receivedMethods.push(req.method); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("http-methods-test", serverSecret); + + for (const method of methods) { + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method, + }); + expect(response.status).toBe(200); + } + + expect(receivedMethods).toEqual(methods); + }); + + it("should handle HEAD request correctly", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "head-request-test", + onRequest: async (req) => { + if (req.method === "HEAD") { + return new Response(null, { + headers: { + "Content-Length": "1000", + "Content-Type": "text/plain", + }, + }); + } + return new Response("body content", { + headers: { + "Content-Type": "text/plain", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("head-request-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "HEAD", + }); + + expect(response.status).toBe(200); + // HEAD should return headers but no body + expect(response.headers.get("content-type")).toBe("text/plain"); + const body = await response.text(); + expect(body).toBe(""); + }); + + it("should handle OPTIONS request for CORS", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "options-cors-test", + onRequest: async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); + } + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("options-cors-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "OPTIONS", + headers: { + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toContain( + "POST" + ); + }); + }); + }); +} diff --git a/packages/devhook/src/test-utils.ts b/packages/devhook/src/test-utils.ts new file mode 100644 index 0000000..e2effcd --- /dev/null +++ b/packages/devhook/src/test-utils.ts @@ -0,0 +1,144 @@ +/** + * Shared test utilities for testing both local and Cloudflare servers. + * + * This module provides a common interface for server implementations + * so the same tests can run against both. + */ + +import { + DevhookClient, + type DevhookClientOptions, + type WebSocketRequest, +} from "./client"; +import { generateDevhookId } from "./server/crypto"; + +/** + * Common interface for devhook server implementations. + * Both local and Cloudflare servers should implement this. + */ +export interface TestServer { + /** The base URL of the server (e.g., http://localhost:8080) */ + readonly url: string; + + /** The server secret used for HMAC signing */ + readonly secret: string; + + /** Close/cleanup the server */ + close(): Promise | void; +} + +/** + * Factory function type for creating test servers. + */ +export type TestServerFactory = () => Promise; + +/** + * Options for creating a test client. + */ +export interface TestClientOptions { + server: TestServer; + secret: string; + localTargetPort?: number; + transformWebSocketRequest?: (request: WebSocketRequest) => WebSocketRequest; + onRequest?: DevhookClientOptions["onRequest"]; + onConnect?: DevhookClientOptions["onConnect"]; + onDisconnect?: DevhookClientOptions["onDisconnect"]; + onError?: DevhookClientOptions["onError"]; +} + +/** + * Create a DevhookClient configured for testing. + */ +export function createTestClient(opts: TestClientOptions): DevhookClient { + const { + server, + secret, + localTargetPort, + transformWebSocketRequest, + onRequest, + ...rest + } = opts; + + return new DevhookClient({ + serverUrl: server.url, + secret, + transformWebSocketRequest: + transformWebSocketRequest ?? + (localTargetPort + ? ({ url, headers }) => { + url.host = `localhost:${localTargetPort}`; + return { url, headers }; + } + : undefined), + onRequest: + onRequest ?? + (async (req) => { + if (localTargetPort) { + const url = new URL(req.url); + url.host = `localhost:${localTargetPort}`; + return fetch(new Request(url.toString(), req)); + } + return new Response("No handler configured", { status: 500 }); + }), + ...rest, + }); +} + +/** + * Helper to generate a devhook ID for testing. + */ +export async function getDevhookId( + clientSecret: string, + serverSecret: string +): Promise { + return generateDevhookId(clientSecret, serverSecret); +} + +/** + * Helper to build the devhook URL for a given ID. + */ +export function getDevhookUrl( + server: TestServer, + devhookId: string, + path = "" +): string { + return `${server.url}/devhook/${devhookId}${path}`; +} + +/** + * Helper to build the WebSocket URL for a devhook. + */ +export function getDevhookWsUrl( + server: TestServer, + devhookId: string, + path = "" +): string { + const url = new URL(getDevhookUrl(server, devhookId, path)); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +/** + * Wait for a condition with timeout. + */ +export async function waitFor( + condition: () => boolean | Promise, + timeoutMs = 5000, + intervalMs = 50 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Timeout waiting for condition after ${timeoutMs}ms`); +} + +/** + * Delay helper. + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/devhook/tsconfig.json b/packages/devhook/tsconfig.json new file mode 100644 index 0000000..80150f7 --- /dev/null +++ b/packages/devhook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/devhook/tsdown.config.ts b/packages/devhook/tsdown.config.ts new file mode 100644 index 0000000..b8bbd91 --- /dev/null +++ b/packages/devhook/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, +}); diff --git a/packages/devhook/wrangler.toml b/packages/devhook/wrangler.toml new file mode 100644 index 0000000..63f3edf --- /dev/null +++ b/packages/devhook/wrangler.toml @@ -0,0 +1,41 @@ +# Example wrangler.toml for deploying the devhook server. +# +# To deploy: +# 1. Copy this file to your project +# 2. Update the configuration values +# 3. Run: wrangler deploy + +name = "devhook-server" +main = "src/server/cloudflare.ts" +compatibility_date = "2025-01-01" + +# For wildcard subdomains, configure routes like: +# routes = [ +# { pattern = "*.devhook.example.com/*", zone_name = "example.com" }, +# { pattern = "devhook.example.com/*", zone_name = "example.com" } +# ] +# +# For subpath mode, a single route suffices: +# routes = [ +# { pattern = "example.com/devhook/*", zone_name = "example.com" } +# ] + +[vars] +# Server secret for HMAC signing. Change this! +DEVHOOK_SECRET = "change-me-to-a-secure-random-string" + +# Base URL for generating public URLs +# For wildcard mode: https://devhook.example.com +# For subpath mode: https://example.com +DEVHOOK_BASE_URL = "https://devhook.example.com" + +# Routing mode: "wildcard" or "subpath" +DEVHOOK_MODE = "wildcard" + +[[durable_objects.bindings]] +name = "DEVHOOK_SESSION" +class_name = "DevhookSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["DevhookSession"]