diff --git a/.gitignore b/.gitignore index 1d3285d..b289cfb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ yarn-error.log* .idea/ .vscode/ *.swp +.claude/settings.local.json # OS .DS_Store diff --git a/package-lock.json b/package-lock.json index 1b36996..771e909 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,53 @@ "vitest": "^3.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -32,6 +79,18 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -163,6 +222,140 @@ "node": ">=12" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", @@ -828,6 +1021,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1420,6 +1630,10 @@ "resolved": "packages/server", "link": true }, + "node_modules/@meterx/shared": { + "resolved": "packages/shared", + "link": true + }, "node_modules/@meterx/worker": { "resolved": "packages/worker", "link": true @@ -2623,6 +2837,15 @@ "node": ">= 0.6.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -2963,6 +3186,38 @@ "utrie": "^1.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-tree/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2980,6 +3235,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/decode-named-character-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", @@ -3114,6 +3375,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -3925,6 +4198,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -4057,6 +4342,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4090,6 +4381,55 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4203,6 +4543,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5368,6 +5717,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5599,6 +5960,15 @@ "license": "MIT", "optional": true }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5741,6 +6111,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6072,6 +6454,12 @@ "node": ">=12.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -6173,6 +6561,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6195,6 +6601,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -7102,6 +7532,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7720,6 +8194,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7808,11 +8297,13 @@ "version": "2026.0.319-MR1.0", "license": "AGPL-3.0", "dependencies": { + "@meterx/shared": "*", "html2canvas": "^1.4.1", "jspdf": "^4.2.1" }, "devDependencies": { - "@types/chrome": "^0.0.287" + "@types/chrome": "^0.0.287", + "jsdom": "^29.0.2" } }, "packages/server": { @@ -7820,6 +8311,7 @@ "version": "2026.0.319-MR1.0", "license": "AGPL-3.0", "dependencies": { + "@meterx/shared": "*", "cors": "^2.8.5", "express": "^5.1.0", "express-rate-limit": "^7.5.0" @@ -7832,10 +8324,18 @@ "supertest": "^7.0.0" } }, + "packages/shared": { + "name": "@meterx/shared", + "version": "1.0.0", + "license": "AGPL-3.0" + }, "packages/worker": { "name": "@meterx/worker", "version": "2026.0.319-MR1.0", "license": "AGPL-3.0", + "dependencies": { + "@meterx/shared": "*" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20250313.0", "wrangler": "^4.8.0" diff --git a/packages/extension/build.mjs b/packages/extension/build.mjs index 9fee6aa..c07c950 100644 --- a/packages/extension/build.mjs +++ b/packages/extension/build.mjs @@ -11,6 +11,7 @@ await build({ resolve(__dirname, 'src/background.ts'), resolve(__dirname, 'src/popup.ts'), resolve(__dirname, 'src/options.ts'), + resolve(__dirname, 'src/sidepanel.ts'), ], bundle: true, outdir: resolve(__dirname, 'dist'), diff --git a/packages/extension/package.json b/packages/extension/package.json index 764e784..6c2e54f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,12 +4,15 @@ "private": true, "license": "AGPL-3.0", "scripts": { - "build": "node build.mjs" + "build": "node build.mjs", + "test": "npx vitest run" }, "devDependencies": { - "@types/chrome": "^0.0.287" + "@types/chrome": "^0.0.287", + "jsdom": "^29.0.2" }, "dependencies": { + "@meterx/shared": "*", "html2canvas": "^1.4.1", "jspdf": "^4.2.1" } diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index f0657a0..7cdb89e 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -7,17 +7,27 @@ /// +import type { TestProgress, TestResults, PingResult } from '@meterx/shared'; + // --- Configuration --- const DEFAULT_SERVER_URL = 'https://meterx-speedtest.meterx-ajithakdev.workers.dev'; -const DOWNLOAD_FILE_MB = 10; // 10MB for better TCP ramp-up (M-Lab uses 9-60s tests) -const DOWNLOAD_FILE_PATH = `/test-file/${DOWNLOAD_FILE_MB}MB.bin`; -const UPLOAD_DATA_SIZE_MB = 5; +const DOWNLOAD_CHUNK_MB = 25; // biggest allowed chunk per stream (server whitelist max) +const DOWNLOAD_CHUNK_PATH = `/test-file/${DOWNLOAD_CHUNK_MB}MB.bin`; +const UPLOAD_CHUNK_MB = 5; // body size per POST stream const UPLOAD_ENDPOINT_PATH = '/upload'; const PING_ENDPOINT_PATH = '/ping'; const PING_COUNT = 5; -const FETCH_DOWNLOAD_TIMEOUT = 60000; -const FETCH_UPLOAD_TIMEOUT = 60000; +// Multi-stream duration-based measurement — matches M-Lab / Ookla approach. +// 4 parallel streams let us saturate typical home links up to ~gigabit. +const PARALLEL_DOWNLOAD_STREAMS = 4; +const PARALLEL_UPLOAD_STREAMS = 3; +const MEASURE_DURATION_MS = 8000; +const RAMP_UP_TRIM_MS = 1200; // drop first 1.2s while TCP ramps up +const TAIL_TRIM_MS = 500; // drop last 0.5s to avoid partial chunk skew const FETCH_PING_TIMEOUT = 5000; +const LOADED_PING_INTERVAL_MS = 300; // probe ping every 300ms during load +const SCHEDULE_ALARM_NAME = 'meterx-scheduled-test'; +const QUALITY_DROP_NOTIFICATION_ID = 'meterx-quality-drop'; // --- Server URL (configurable via options page) --- async function getServerUrl(): Promise { @@ -29,29 +39,13 @@ async function getServerUrl(): Promise { } } -// --- Types --- -interface TestProgress { - status?: string; - downloadSpeed?: number; - uploadSpeed?: number; - ping?: number; - jitter?: number; - packetLoss?: number; -} - -interface TestResults { - downloadSpeed?: number; - uploadSpeed?: number; - ping?: number; - jitter?: number; - packetLoss?: number; - status: string; -} - -interface PingResult { - ping: number; - jitter: number; - packetLoss: number; +// --- Bufferbloat grading --- +function gradeBufferbloat(deltaMs: number): 'A' | 'B' | 'C' | 'D' | 'F' { + if (deltaMs <= 5) return 'A'; + if (deltaMs <= 30) return 'B'; + if (deltaMs <= 60) return 'C'; + if (deltaMs <= 200) return 'D'; + return 'F'; } // --- Messaging Helpers --- @@ -99,6 +93,106 @@ async function fetchWithTimeout(resource: string, options: RequestInit = {}, tim // --- Test Functions --- +// Loaded-ping probe: fires HEAD /ping on an interval while a flag is live. +// Returns array of observed latencies in ms. Never throws — individual probe +// failures are silently skipped because we only care about aggregate delay. +interface LoadedPingHandle { + stop: () => Promise; +} + +function startLoadedPingProbe(baseUrl: string): LoadedPingHandle { + const samples: number[] = []; + let stopped = false; + let wakeSleep: (() => void) | null = null; + + async function loop(): Promise { + while (!stopped) { + const t0 = performance.now(); + try { + const ctl = new AbortController(); + const to = setTimeout(() => ctl.abort(), FETCH_PING_TIMEOUT); + const res = await fetch(`${baseUrl}${PING_ENDPOINT_PATH}?load=${Date.now()}`, { method: 'HEAD', cache: 'no-store', signal: ctl.signal }); + clearTimeout(to); + if (res.ok) samples.push(performance.now() - t0); + } catch { /* skip */ } + if (stopped) break; + // Sleep, but be interruptible by stop() so the loop doesn't hang + // the outer `await runner` when tests cancel fast. + await new Promise(resolve => { + const timer = setTimeout(() => { wakeSleep = null; resolve(); }, LOADED_PING_INTERVAL_MS); + wakeSleep = () => { clearTimeout(timer); wakeSleep = null; resolve(); }; + }); + } + } + + const runner = loop(); + + return { + stop: async () => { + stopped = true; + if (wakeSleep) wakeSleep(); + await runner; + return samples; + }, + }; +} + +async function detectIpFamily(baseUrl: string): Promise<{ ipv4Ok?: boolean; ipv6Ok?: boolean }> { + try { + const res = await fetchWithTimeout(`${baseUrl}/ip?t=${Date.now()}`, { cache: 'no-store' }, 5000); + if (!res.ok) return {}; + const data = await res.json() as { family?: string }; + if (data.family === 'ipv4') return { ipv4Ok: true }; + if (data.family === 'ipv6') return { ipv6Ok: true }; + return {}; + } catch { + return {}; + } +} + +function extractDnsMs(urlPrefix: string): number | undefined { + // Scan recent resource entries for the most recent request to our server. + // Returns domainLookupEnd - domainLookupStart if the origin honored + // Timing-Allow-Origin; otherwise returns undefined (zeros are filtered out). + try { + const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (!e.name.startsWith(urlPrefix)) continue; + const dns = e.domainLookupEnd - e.domainLookupStart; + if (dns > 0) return dns; + } + } catch { /* no perf api */ } + return undefined; +} + +// Timestamped cumulative-bytes sample used by windowed speed calc. +interface ByteSample { t: number; bytes: number } + +// Given samples ordered by t, return cumulative bytes at time tTarget +// by linear interpolation between adjacent samples. +function bytesAtTime(samples: ByteSample[], tTarget: number): number { + if (samples.length === 0) return 0; + if (tTarget <= samples[0].t) return samples[0].bytes; + if (tTarget >= samples[samples.length - 1].t) return samples[samples.length - 1].bytes; + for (let i = 1; i < samples.length; i++) { + if (samples[i].t >= tTarget) { + const a = samples[i - 1]; + const b = samples[i]; + const ratio = (tTarget - a.t) / (b.t - a.t || 1); + return a.bytes + (b.bytes - a.bytes) * ratio; + } + } + return samples[samples.length - 1].bytes; +} + +function speedFromWindow(samples: ByteSample[], startMs: number, endMs: number): number { + const durS = (endMs - startMs) / 1000; + if (durS <= 0) return 0; + const delta = bytesAtTime(samples, endMs) - bytesAtTime(samples, startMs); + return (delta * 8 / durS) / (1024 * 1024); +} + async function measureDownloadSpeed(baseUrl: string): Promise { // Warm-up: establish TCP + TLS connection before timing await sendProgress({ status: 'Warming up connection...' }); @@ -107,50 +201,66 @@ async function measureDownloadSpeed(baseUrl: string): Promise { } catch { /* warmup failure is non-fatal */ } await sendProgress({ status: 'Downloading test file...' }); - const url = `${baseUrl}${DOWNLOAD_FILE_PATH}?t=${Date.now()}`; - - const response = await fetchWithTimeout(url, { cache: 'no-store' }, FETCH_DOWNLOAD_TIMEOUT); - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); - } - - // Stream the response to show live progress, measure total bytes / total time - const reader = response.body?.getReader(); - if (!reader) { - const data = await response.arrayBuffer(); - const endTime = performance.now(); - const dur = (endTime - performance.now()) / 1000 || 1; - return (data.byteLength * 8 / dur) / (1024 * 1024); - } let totalBytes = 0; const startTime = performance.now(); - let lastProgressBytes = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - lastProgressBytes += value.byteLength; - - // Send live speed updates every ~500KB - if (lastProgressBytes >= 512 * 1024) { - const now = performance.now(); - const elapsed = (now - startTime) / 1000; - if (elapsed > 0) { - const liveSpeed = (totalBytes * 8 / elapsed) / (1024 * 1024); - await sendProgress({ downloadSpeed: liveSpeed, status: 'Downloading...' }); - } - lastProgressBytes = 0; + const testEndTarget = startTime + MEASURE_DURATION_MS; + const samples: ByteSample[] = [{ t: 0, bytes: 0 }]; + const controllers: AbortController[] = []; + let lastProgressMs = 0; + + async function streamOne(streamId: number): Promise { + while (performance.now() < testEndTarget && !testAbortController?.signal.aborted) { + const ctl = new AbortController(); + controllers.push(ctl); + try { + const res = await fetch(`${baseUrl}${DOWNLOAD_CHUNK_PATH}?t=${Date.now()}_${streamId}`, { + cache: 'no-store', + signal: ctl.signal, + }); + if (!res.ok || !res.body) throw new Error(`stream ${streamId} http ${res.status}`); + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (performance.now() >= testEndTarget) { + try { await reader.cancel(); } catch { /* ignore */ } + try { ctl.abort(); } catch { /* ignore */ } + break; + } + totalBytes += value.byteLength; + const t = performance.now() - startTime; + samples.push({ t, bytes: totalBytes }); + + if (t - lastProgressMs > 250) { + lastProgressMs = t; + const elapsedS = t / 1000; + if (elapsedS > 0.5) { + const liveSpeed = (totalBytes * 8 / elapsedS) / (1024 * 1024); + await sendProgress({ downloadSpeed: liveSpeed, status: 'Downloading...' }); + } + } + } + } catch { /* stream dropped or aborted — another iteration will take over */ } } } - const endTime = performance.now(); - const totalDuration = (endTime - startTime) / 1000; - if (totalDuration === 0) throw new Error("Download too fast to measure"); - - // Simple total bytes / total time — same method as M-Lab - const speedMbps = (totalBytes * 8 / totalDuration) / (1024 * 1024); + const streams = Array.from({ length: PARALLEL_DOWNLOAD_STREAMS }, (_, i) => streamOne(i)); + const hardDeadline = new Promise(resolve => setTimeout(resolve, MEASURE_DURATION_MS + 2000)); + await Promise.race([Promise.all(streams), hardDeadline]); + controllers.forEach(c => { try { c.abort(); } catch { /* ignore */ } }); + + // Windowed speed: trim ramp-up and tail to get steady-state throughput. + const usableEnd = Math.min(MEASURE_DURATION_MS - TAIL_TRIM_MS, performance.now() - startTime); + const windowStart = RAMP_UP_TRIM_MS; + if (usableEnd <= windowStart) { + // test was too short — fall back to naive total/time + const totalS = (performance.now() - startTime) / 1000 || 0.001; + const speed = (totalBytes * 8 / totalS) / (1024 * 1024); + await sendProgress({ downloadSpeed: speed, status: 'Download complete' }); + return speed; + } + const speedMbps = speedFromWindow(samples, windowStart, usableEnd); await sendProgress({ downloadSpeed: speedMbps, status: 'Download complete' }); return speedMbps; } @@ -163,25 +273,67 @@ async function measureUploadSpeed(baseUrl: string): Promise { } catch { /* non-fatal */ } await sendProgress({ status: 'Uploading test data...' }); - const url = `${baseUrl}${UPLOAD_ENDPOINT_PATH}?t=${Date.now()}`; - const dataSize = UPLOAD_DATA_SIZE_MB * 1024 * 1024; - const dataToSend = new Uint8Array(dataSize); + const uploadSize = UPLOAD_CHUNK_MB * 1024 * 1024; + const payload = new Uint8Array(uploadSize); // shared across streams, immutable + + let totalBytes = 0; const startTime = performance.now(); - const response = await fetchWithTimeout(url, { - method: 'POST', - cache: 'no-store', - headers: { 'Content-Type': 'application/octet-stream' }, - body: dataToSend - }, FETCH_UPLOAD_TIMEOUT); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); - await response.text(); - const endTime = performance.now(); - const durationSeconds = (endTime - startTime) / 1000; - if (durationSeconds === 0) throw new Error("Upload too fast to measure"); - - const bitsUploaded = dataSize * 8; - const speedMbps = (bitsUploaded / durationSeconds) / (1024 * 1024); + const endTarget = startTime + MEASURE_DURATION_MS; + const samples: ByteSample[] = [{ t: 0, bytes: 0 }]; + const controllers: AbortController[] = []; + let lastProgressMs = 0; + + async function uploadOne(streamId: number): Promise { + while (performance.now() < endTarget && !testAbortController?.signal.aborted) { + const ctl = new AbortController(); + controllers.push(ctl); + const chunkStart = performance.now(); + try { + const res = await fetch(`${baseUrl}${UPLOAD_ENDPOINT_PATH}?t=${Date.now()}_${streamId}`, { + method: 'POST', + cache: 'no-store', + headers: { 'Content-Type': 'application/octet-stream' }, + body: payload, + signal: ctl.signal, + }); + if (!res.ok) throw new Error(`upload ${streamId} http ${res.status}`); + await res.text(); + // Credit bytes at completion time (fetch API won't stream upload progress in MV3) + totalBytes += uploadSize; + const t = performance.now() - startTime; + samples.push({ t, bytes: totalBytes }); + + if (t - lastProgressMs > 250) { + lastProgressMs = t; + const elapsedS = t / 1000; + if (elapsedS > 0.5) { + const liveSpeed = (totalBytes * 8 / elapsedS) / (1024 * 1024); + await sendProgress({ uploadSpeed: liveSpeed, status: 'Uploading...' }); + } + } + // Avoid an endless-fast loop for tiny chunks + if (performance.now() - chunkStart < 50) { + await new Promise(r => setTimeout(r, 50)); + } + } catch { /* drop this stream iteration */ } + } + } + + const streams = Array.from({ length: PARALLEL_UPLOAD_STREAMS }, (_, i) => uploadOne(i)); + const hardDeadline = new Promise(resolve => setTimeout(resolve, MEASURE_DURATION_MS + 2000)); + await Promise.race([Promise.all(streams), hardDeadline]); + controllers.forEach(c => { try { c.abort(); } catch { /* ignore */ } }); + + const usableEnd = Math.min(MEASURE_DURATION_MS - TAIL_TRIM_MS, performance.now() - startTime); + const windowStart = RAMP_UP_TRIM_MS; + if (usableEnd <= windowStart) { + const totalS = (performance.now() - startTime) / 1000 || 0.001; + const speed = (totalBytes * 8 / totalS) / (1024 * 1024); + await sendProgress({ uploadSpeed: speed, status: 'Upload complete' }); + return speed; + } + const speedMbps = speedFromWindow(samples, windowStart, usableEnd); await sendProgress({ uploadSpeed: speedMbps, status: 'Upload complete' }); return speedMbps; } @@ -266,8 +418,20 @@ async function runFullTest(): Promise { const baseUrl = await getServerUrl(); await sendProgress({ status: results.status }); + // IP family detect runs early and non-fatally + detectIpFamily(baseUrl).then(f => { + results.ipv4Ok = f.ipv4Ok; + results.ipv6Ok = f.ipv6Ok; + }).catch(() => { /* non-fatal */ }); + checkCancelled(); + const loadedProbe = startLoadedPingProbe(baseUrl); results.downloadSpeed = await measureDownloadSpeed(baseUrl); + const loadedSamples = await loadedProbe.stop(); + + // DNS timing captured from the most recent resource entry to our server + results.dnsLookupMs = extractDnsMs(baseUrl); + checkCancelled(); results.uploadSpeed = await measureUploadSpeed(baseUrl); checkCancelled(); @@ -280,6 +444,15 @@ async function runFullTest(): Promise { results.jitter = pingResult.jitter; results.packetLoss = pingResult.packetLoss; + // Bufferbloat = loaded ping median minus idle ping. Graded A-F. + if (loadedSamples.length > 0 && results.ping !== undefined) { + const sorted = [...loadedSamples].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const delta = Math.max(0, median - results.ping); + results.bufferbloatDelta = delta; + results.bufferbloatGrade = gradeBufferbloat(delta); + } + results.status = 'Test complete'; // Set badge with download speed @@ -305,6 +478,62 @@ async function runFullTest(): Promise { return results; } +// --- Scheduled background tests --- +async function rescheduleTestsFromPrefs(): Promise { + try { + const { scheduleMinutes } = await chrome.storage.sync.get('scheduleMinutes'); + const minutes = Number(scheduleMinutes) || 0; + await chrome.alarms.clear(SCHEDULE_ALARM_NAME); + if (minutes > 0) { + chrome.alarms.create(SCHEDULE_ALARM_NAME, { periodInMinutes: minutes, delayInMinutes: minutes }); + } + } catch (e) { + console.error('Reschedule failed:', e); + } +} + +function qualityGrade(dl?: number, ul?: number, ping?: number): 'excellent' | 'good' | 'fair' | 'poor' | null { + if (dl === undefined || ul === undefined || ping === undefined) return null; + if (dl >= 50 && ul >= 20 && ping < 30) return 'excellent'; + if (dl >= 20 && ul >= 10 && ping < 60) return 'good'; + if (dl >= 5 && ul >= 2 && ping < 150) return 'fair'; + return 'poor'; +} + +async function maybeNotifyQualityDrop(current: TestResults): Promise { + try { + const { notifyOnDrop } = await chrome.storage.sync.get('notifyOnDrop'); + if (!notifyOnDrop) return; + const grade = qualityGrade(current.downloadSpeed, current.uploadSpeed, current.ping); + if (grade !== 'poor') return; + await chrome.notifications.create(QUALITY_DROP_NOTIFICATION_ID, { + type: 'basic', + iconUrl: chrome.runtime.getURL('images/icons128.png'), + title: 'MeterX — connection quality dropped', + message: `Poor: ${(current.downloadSpeed ?? 0).toFixed(1)} Mbps down, ping ${(current.ping ?? 0).toFixed(0)}ms.`, + priority: 1, + }); + } catch (e) { + console.error('Notify failed:', e); + } +} + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== SCHEDULE_ALARM_NAME) return; + const result = await runFullTest(); + if (result.status.startsWith('Test complete')) { + await maybeNotifyQualityDrop(result); + } +}); + +chrome.runtime.onInstalled.addListener(() => { rescheduleTestsFromPrefs(); }); +chrome.runtime.onStartup.addListener(() => { rescheduleTestsFromPrefs(); }); + +// Side Panel — open on action click when popup disabled, plus available via API +if (chrome.sidePanel?.setPanelBehavior) { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => { /* old chrome */ }); +} + chrome.runtime.onMessage.addListener( (message: { action: string; url?: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: unknown) => void) => { if (message.action === "startTest") { @@ -326,6 +555,17 @@ chrome.runtime.onMessage.addListener( getServerUrl().then(url => sendResponse({ url })); return true; } + if (message.action === "rescheduleTests") { + rescheduleTestsFromPrefs().then(() => sendResponse({ success: true })); + return true; + } + if (message.action === "openSidePanel") { + if (chrome.sidePanel?.open && _sender.tab?.windowId !== undefined) { + chrome.sidePanel.open({ windowId: _sender.tab.windowId }).catch(() => { /* not supported */ }); + } + sendResponse({ success: true }); + return false; + } if (message.action === "setServerUrl") { const url = message.url?.trim() || ''; if (url) { diff --git a/packages/extension/src/chart.ts b/packages/extension/src/chart.ts new file mode 100644 index 0000000..b80d0c2 --- /dev/null +++ b/packages/extension/src/chart.ts @@ -0,0 +1,72 @@ +/** + * Side-panel trend chart — inline SVG, no deps. + * Shows download (cyan), upload (purple), ping (green) on a shared time axis. + * @license AGPL-3.0 + */ + +import type { HistoryEntry } from '@meterx/shared'; + +const W = 560; +const H = 120; +const PAD_L = 28; +const PAD_R = 8; +const PAD_T = 8; +const PAD_B = 18; + +export function renderChart(el: HTMLElement, history: HistoryEntry[]): void { + const entries = history.slice(0, 60).reverse(); + if (entries.length < 2) { + el.innerHTML = '
Run two tests to see the trend.
'; + return; + } + + const dl = entries.map(e => e.downloadSpeed ?? null); + const ul = entries.map(e => e.uploadSpeed ?? null); + const pi = entries.map(e => e.ping ?? null); + const times = entries.map(e => e.timestamp); + + const speedMax = Math.max(1, ...dl.filter((v): v is number => v !== null), ...ul.filter((v): v is number => v !== null)); + const pingMax = Math.max(1, ...pi.filter((v): v is number => v !== null)); + + const xStep = (W - PAD_L - PAD_R) / (entries.length - 1); + const xAt = (i: number) => PAD_L + i * xStep; + const yAtSpeed = (v: number) => PAD_T + (H - PAD_T - PAD_B) * (1 - v / speedMax); + const yAtPing = (v: number) => PAD_T + (H - PAD_T - PAD_B) * (1 - v / pingMax); + + const path = (vals: (number | null)[], mapY: (v: number) => number): string => { + let d = ''; + let open = false; + vals.forEach((v, i) => { + if (v === null) { open = false; return; } + d += `${open ? 'L' : 'M'}${xAt(i).toFixed(1)},${mapY(v).toFixed(1)} `; + open = true; + }); + return d.trim(); + }; + + const dlPath = path(dl, yAtSpeed); + const ulPath = path(ul, yAtSpeed); + const piPath = path(pi, yAtPing); + + const firstLabel = new Date(times[0]).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const lastLabel = new Date(times[times.length - 1]).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + + el.innerHTML = ` + + + + ${speedMax.toFixed(0)} + 0 + ${firstLabel} + ${lastLabel} + + + + + Down + Up + Ping + + + `; +} diff --git a/packages/extension/src/export-data.ts b/packages/extension/src/export-data.ts new file mode 100644 index 0000000..fb30970 --- /dev/null +++ b/packages/extension/src/export-data.ts @@ -0,0 +1,72 @@ +/** + * JSON + CSV export for machine-readable workflows. + * @license AGPL-3.0 + */ + +import type { HistoryEntry } from '@meterx/shared'; + +function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +export function exportJson(history: HistoryEntry[]): void { + const payload = { + exportedAt: new Date().toISOString(), + tool: 'MeterX', + version: '2026.0.319-MR1.0', + count: history.length, + results: history.map(e => ({ + timestamp: e.timestamp, + isoTime: new Date(e.timestamp).toISOString(), + downloadMbps: e.downloadSpeed, + uploadMbps: e.uploadSpeed, + pingMs: e.ping, + jitterMs: e.jitter, + packetLossPct: e.packetLoss, + bufferbloatDeltaMs: e.bufferbloatDelta, + bufferbloatGrade: e.bufferbloatGrade, + dnsLookupMs: e.dnsLookupMs, + ipv4Ok: e.ipv4Ok, + ipv6Ok: e.ipv6Ok, + edge: e.edge, + })), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + triggerDownload(blob, `meterx-export-${new Date().toLocaleDateString('en-CA')}.json`); +} + +export function exportCsv(history: HistoryEntry[]): void { + const cols = ['timestamp', 'isoTime', 'downloadMbps', 'uploadMbps', 'pingMs', 'jitterMs', 'packetLossPct', 'bufferbloatDeltaMs', 'bufferbloatGrade', 'dnsLookupMs', 'ipv4Ok', 'ipv6Ok', 'edge']; + const rows = history.map(e => [ + e.timestamp, + new Date(e.timestamp).toISOString(), + e.downloadSpeed ?? '', + e.uploadSpeed ?? '', + e.ping ?? '', + e.jitter ?? '', + e.packetLoss ?? '', + e.bufferbloatDelta ?? '', + e.bufferbloatGrade ?? '', + e.dnsLookupMs ?? '', + e.ipv4Ok ?? '', + e.ipv6Ok ?? '', + e.edge ?? '', + ]); + const csv = [cols.join(','), ...rows.map(r => r.map(escapeCsv).join(','))].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + triggerDownload(blob, `meterx-export-${new Date().toLocaleDateString('en-CA')}.csv`); +} + +function escapeCsv(v: unknown): string { + if (v === null || v === undefined) return ''; + const s = String(v); + if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} diff --git a/packages/extension/src/export-pdf.ts b/packages/extension/src/export-pdf.ts new file mode 100644 index 0000000..f3f77ca --- /dev/null +++ b/packages/extension/src/export-pdf.ts @@ -0,0 +1,298 @@ +/** + * PDF report generation — heavy imports (html2canvas, jspdf) loaded on demand. + * @license AGPL-3.0 + */ + +import type { HistoryEntry } from '@meterx/shared'; +import { getQuality } from './quality'; + +interface ConnInfo { type?: string; effectiveType?: string } + +export async function exportPdfReport(hist: HistoryEntry[]): Promise { + if (hist.length === 0) throw new Error('No data to export'); + + const avg = (a: number[]) => a.length ? a.reduce((s, v) => s + v, 0) / a.length : 0; + const mn = (a: number[]) => a.length ? Math.min(...a) : 0; + const mx = (a: number[]) => a.length ? Math.max(...a) : 0; + const sd = (a: number[]) => { const m = avg(a); return Math.sqrt(avg(a.map(v => (v - m) ** 2))); }; + + const dlV = hist.filter(e => e.downloadSpeed !== undefined).map(e => e.downloadSpeed!); + const ulV = hist.filter(e => e.uploadSpeed !== undefined).map(e => e.uploadSpeed!); + const piV = hist.filter(e => e.ping !== undefined).map(e => e.ping!); + const jiV = hist.filter(e => e.jitter !== undefined).map(e => e.jitter!); + const loV = hist.filter(e => e.packetLoss !== undefined).map(e => e.packetLoss!); + + const aDl = avg(dlV), aUl = avg(ulV), aPi = avg(piV), aJi = avg(jiV), aLo = avg(loV); + const qual = getQuality(aDl, aUl, aPi); + const cons = dlV.length > 1 ? Math.max(0, Math.min(100, 100 - (sd(dlV) / aDl) * 100)) : 100; + const consLabel = cons >= 85 ? 'Stable' : cons >= 70 ? 'Moderate' : cons >= 50 ? 'Unstable' : 'Very unstable'; + const anomThresh = aDl * 0.5; + const anomCount = dlV.filter(v => v < anomThresh).length; + const halfIdx = Math.floor(dlV.length / 2); + const trendPct = avg(dlV.slice(halfIdx)) > 0 ? ((avg(dlV.slice(0, halfIdx)) - avg(dlV.slice(halfIdx))) / avg(dlV.slice(halfIdx))) * 100 : 0; + const trendLabel = Math.abs(trendPct) < 5 ? 'Stable' : trendPct > 0 ? `Improving (+${trendPct.toFixed(0)}%)` : `Declining (${trendPct.toFixed(0)}%)`; + + const now = new Date(); + const oldest = new Date(hist[hist.length - 1].timestamp); + const newest = new Date(hist[0].timestamp); + const spanMin = Math.floor((newest.getTime() - oldest.getTime()) / 60000); + const period = spanMin < 60 ? `${spanMin} min session` : spanMin < 1440 ? `${Math.floor(spanMin / 60)} hour session` : `${Math.floor(spanMin / 1440)} days`; + + const qCol = qual.cls === 'excellent' ? '#34d399' : qual.cls === 'good' ? '#60a5fa' : qual.cls === 'fair' ? '#fbbf24' : '#f87171'; + const consCol = cons >= 85 ? '#34d399' : cons >= 70 ? '#fbbf24' : '#f87171'; + const trendCol = Math.abs(trendPct) < 5 ? '#8888a0' : trendPct > 0 ? '#34d399' : '#f87171'; + + const conn = (navigator as unknown as { connection?: ConnInfo }).connection; + const envParts: string[] = []; + if (conn?.type && conn.type !== 'unknown') envParts.push(`Connection: ${({ wifi: 'WiFi', ethernet: 'Ethernet', cellular: 'Cellular' } as Record)[conn.type] || conn.type}`); + if (conn?.effectiveType) envParts.push(`Speed tier: ${conn.effectiveType.toUpperCase()}`); + + const gamSub = aPi < 50 ? 'Competitive OK' : aPi < 100 ? 'Casual only' : 'Not recommended'; + const ucData = [ + { l: 'Video Calls', s: 'Zoom, Meet, Teams', sc: (aDl >= 5 && aUl >= 3 && aPi < 100 && aJi < 30) ? 'Good' : (aDl >= 2 && aUl >= 1) ? 'Moderate' : 'Poor' }, + { l: '4K Streaming', s: 'Netflix, YouTube', sc: aDl >= 25 ? 'Good' : aDl >= 5 ? 'HD only' : 'Poor' }, + { l: 'Gaming', s: gamSub, sc: (aPi < 50 && aJi < 20 && aLo < 1) ? 'Good' : aPi < 100 ? 'Moderate' : 'Poor' }, + { l: 'Uploads', s: 'Cloud sync, files', sc: aUl >= 10 ? 'Good' : aUl >= 3 ? 'Moderate' : 'Poor' }, + ]; + + const ucColor = (sc: string) => sc === 'Good' ? '#34d399' : sc === 'Poor' ? '#f87171' : '#fbbf24'; + const ucBg = (sc: string) => sc === 'Good' ? '#0d1f17' : sc === 'Poor' ? '#1f0d0d' : '#1f1a0d'; + + const html = ` +MeterX Network Health Report + +
MeterX
+
+ +
+

Network Health Report

+
+ ${now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}, ${now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+ ${period} · ${hist.length} tests +
+
+ +
+
+
+
${qual.label}
+
Network Quality
+
+
+
${cons.toFixed(0)}% stable (${consLabel})
+
Based on download speed variation
+
Trend: ${trendLabel}
+ ${anomCount > 0 + ? `
${anomCount} anomal${anomCount === 1 ? 'y' : 'ies'} — DL dropped below ${anomThresh.toFixed(0)} Mbps
` + : '
No anomalies detected
'} +
+
+
+ +${envParts.length > 0 ? `
${envParts.join(' · ')}
` : ''} + +
+
+ ${[{ l: 'Download', a: aDl, lo: mn(dlV), hi: mx(dlV), u: 'Mbps', c: '#00c8ff' }, + { l: 'Upload', a: aUl, lo: mn(ulV), hi: mx(ulV), u: 'Mbps', c: '#a855f7' }, + { l: 'Ping', a: aPi, lo: mn(piV), hi: mx(piV), u: 'ms', c: '#34d399' }] + .map(m => `
+
${m.l}
+
${m.a.toFixed(1)}
+
${m.u}
+
Range: ${m.lo.toFixed(1)} – ${m.hi.toFixed(1)}
+
`).join('')} +
+ +
+
+
Jitter
${aJi.toFixed(1)} ms
+
Range: ${mn(jiV).toFixed(1)} – ${mx(jiV).toFixed(1)}
+
+
+
Packet Loss
${aLo.toFixed(1)} %
+ ${mn(loV) === mx(loV) && aLo === 0 ? '
No loss detected
' : `
Range: ${mn(loV).toFixed(1)} – ${mx(loV).toFixed(1)}
`} +
+
+ +
+

Use-Case Assessment

+
+ ${ucData.map(c => `
+
${c.l}
+
${c.sc}
+
${c.s}
+
`).join('')} +
+ +
+

Test History

+ + + + ${hist.map(e => { + const d = new Date(e.timestamp); + const dt = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const isA = e.downloadSpeed !== undefined && e.downloadSpeed < anomThresh; + return ` + + + + + + + + `; + }).join('')} + +
Date / TimeDL (Mbps)UL (Mbps)Ping (ms)Jitter (ms)Loss (%)
${dt}${e.downloadSpeed !== undefined ? e.downloadSpeed.toFixed(1) : '-'}${e.uploadSpeed !== undefined ? e.uploadSpeed.toFixed(1) : '-'}${e.ping !== undefined ? e.ping.toFixed(1) : '-'}${e.jitter !== undefined ? e.jitter.toFixed(1) : '-'}${e.packetLoss !== undefined ? e.packetLoss.toFixed(0) : '-'}${isA ? 'ANOMALY' : ''}
+ +`; + + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;left:-9999px;top:0;width:794px;visibility:visible;'; + container.innerHTML = html.replace(/[\s\S]*?]*>/, '').replace(/<\/body>[\s\S]*/, ''); + + const styleMatch = html.match(/ -
MeterX
-
- -
-

Network Health Report

-
- ${now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}, ${now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
- ${period} · ${hist.length} tests -
-
- -
-
-
-
${qual.label}
-
Network Quality
-
-
-
${cons.toFixed(0)}% stable (${consLabel})
-
Based on download speed variation
-
Trend: ${trendLabel}
- ${anomCount > 0 - ? `
${anomCount} anomal${anomCount === 1 ? 'y' : 'ies'} — DL dropped below ${anomThresh.toFixed(0)} Mbps
` - : '
No anomalies detected
'} -
-
-
- -${envParts.length > 0 ? `
${envParts.join(' · ')}
` : ''} - -
-
- ${[{ l: 'Download', a: aDl, lo: mn(dlV), hi: mx(dlV), u: 'Mbps', c: '#00c8ff' }, - { l: 'Upload', a: aUl, lo: mn(ulV), hi: mx(ulV), u: 'Mbps', c: '#a855f7' }, - { l: 'Ping', a: aPi, lo: mn(piV), hi: mx(piV), u: 'ms', c: '#34d399' }] - .map(m => `
-
${m.l}
-
${m.a.toFixed(1)}
-
${m.u}
-
Range: ${m.lo.toFixed(1)} – ${m.hi.toFixed(1)}
-
`).join('')} -
- -
-
-
Jitter
${aJi.toFixed(1)} ms
-
Range: ${mn(jiV).toFixed(1)} – ${mx(jiV).toFixed(1)}
-
-
-
Packet Loss
${aLo.toFixed(1)} %
- ${mn(loV) === mx(loV) && aLo === 0 ? '
No loss detected
' : `
Range: ${mn(loV).toFixed(1)} – ${mx(loV).toFixed(1)}
`} -
-
- -
-

Use-Case Assessment

-
- ${ucData.map(c => `
-
${c.l}
-
${c.sc}
-
${c.s}
-
`).join('')} -
- -
-

Test History

- - - - ${hist.map(e => { - const d = new Date(e.timestamp); - const dt = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - const isA = e.downloadSpeed !== undefined && e.downloadSpeed < anomThresh; - return ` - - - - - - - - `; - }).join('')} - -
Date / TimeDL (Mbps)UL (Mbps)Ping (ms)Jitter (ms)Loss (%)
${dt}${e.downloadSpeed !== undefined ? e.downloadSpeed.toFixed(1) : '-'}${e.uploadSpeed !== undefined ? e.uploadSpeed.toFixed(1) : '-'}${e.ping !== undefined ? e.ping.toFixed(1) : '-'}${e.jitter !== undefined ? e.jitter.toFixed(1) : '-'}${e.packetLoss !== undefined ? e.packetLoss.toFixed(0) : '-'}${isA ? 'ANOMALY' : ''}
- -`; - - // Render HTML to hidden container, capture with html2canvas, save as PDF - const container = document.createElement('div'); - container.style.cssText = 'position:fixed;left:-9999px;top:0;width:794px;visibility:visible;'; - container.innerHTML = html.replace(/[\s\S]*?]*>/, '').replace(/<\/body>[\s\S]*/, ''); - - // Inject styles into container + override body padding as container padding - const styleMatch = html.match(/