diff --git a/package-lock.json b/package-lock.json index 2419490..73d8456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,86 @@ }, "devDependencies": { "@eslint/js": "^9.24.0", + "@vitest/coverage-v8": "^3.1.1", "eslint": "^9.24.0", "globals": "^16.0.0", "vitest": "^3.1.1" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -687,6 +762,55 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -694,6 +818,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", @@ -1076,16 +1222,50 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -1094,13 +1274,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1121,9 +1301,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1134,13 +1314,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -1149,13 +1329,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1164,9 +1344,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1177,13 +1357,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -1231,6 +1411,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1264,6 +1457,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1441,6 +1646,13 @@ "url": "https://dotenvx.com" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1450,6 +1662,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1776,6 +1995,23 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1791,6 +2027,28 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1804,6 +2062,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -1827,6 +2111,13 @@ "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1874,6 +2165,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1894,10 +2195,80 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -2084,6 +2455,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2094,6 +2472,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2107,6 +2513,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2189,6 +2605,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2222,6 +2645,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2425,6 +2865,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2449,6 +2902,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2475,6 +3032,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2488,6 +3052,60 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2684,20 +3302,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -2727,8 +3345,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, @@ -2799,6 +3417,101 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/src/index.js b/src/index.js index dceaf00..fded7a2 100644 --- a/src/index.js +++ b/src/index.js @@ -32,8 +32,10 @@ if (isNaN(config.maxPayloadBytes) || config.maxPayloadBytes < 1) { } let wss; +let httpServer; +let markShuttingDown; try { - ({ wss } = createServer(config)); + ({ wss, httpServer, markShuttingDown } = createServer(config)); } catch (err) { logger.error("Failed to start server", { error: err.message }); process.exit(1); @@ -42,7 +44,7 @@ try { logger.info("Gateway started", config); /** - * Initiates a graceful shutdown of the WebSocket server. + * Initiates a graceful shutdown of the server. * * Closes the server and waits for existing connections to finish. If the * server does not close within 5 seconds, a forced exit is triggered. @@ -63,8 +65,16 @@ export function shutdown(server, signal) { }, 5000); } -process.on("SIGTERM", () => shutdown(wss, "SIGTERM")); -process.on("SIGINT", () => shutdown(wss, "SIGINT")); +process.on("SIGTERM", () => { + markShuttingDown(); + wss.close(); + shutdown(httpServer, "SIGTERM"); +}); +process.on("SIGINT", () => { + markShuttingDown(); + wss.close(); + shutdown(httpServer, "SIGINT"); +}); process.on("uncaughtException", (err) => { logger.error("Uncaught exception", { error: err.message }); diff --git a/src/room-manager.js b/src/room-manager.js index d5e4804..3eb1ae9 100644 --- a/src/room-manager.js +++ b/src/room-manager.js @@ -1,5 +1,13 @@ import { WebSocket } from "ws"; +/** + * @typedef {Object} BackpressureOptions + * @property {boolean} [enabled=false] - Enable backpressure-aware broadcasting + * @property {number} [highWaterMark=1048576] - Bytes threshold to flag client as slow (default 1MB) + * @property {number} [slowConsumerTimeout=30000] - Ms before terminating slow consumer (default 30s) + * @property {number} [batchSize=100] - Number of sends per event loop tick (default 100) + */ + /** * Manages room membership and message broadcasting for connected WebSocket clients. * @@ -7,13 +15,33 @@ import { WebSocket } from "ws"; * `clientId → WebSocket` so broadcasts are O(members). A reverse index * (`_clientRooms`) enables O(1) lookup of all rooms a client belongs to, * which is used during disconnection cleanup. + * + * When backpressure options are provided, broadcast() operates in a + * non-blocking batched mode with per-client slow consumer detection, + * message coalescing, and automatic eviction. */ export class RoomManager { - constructor() { + /** + * @param {BackpressureOptions} [backpressure] - Backpressure configuration + */ + constructor(backpressure = undefined) { /** @type {Map>} */ this._rooms = new Map(); /** @type {Map>} */ this._clientRooms = new Map(); + + /** @type {BackpressureOptions} */ + this._backpressureOptions = backpressure && backpressure.enabled + ? { + enabled: true, + highWaterMark: backpressure.highWaterMark ?? 1048576, + slowConsumerTimeout: backpressure.slowConsumerTimeout ?? 30000, + batchSize: backpressure.batchSize ?? 100, + } + : { enabled: false }; + + /** @type {Map} */ + this._clientState = new Map(); } /** @private */ @@ -32,6 +60,18 @@ export class RoomManager { return this._clientRooms.get(clientId); } + /** @private */ + _ensureClientState(clientId, ws) { + if (!this._clientState.has(clientId)) { + this._clientState.set(clientId, { + ws, + slowSince: null, + coalescedMessage: null, + }); + } + return this._clientState.get(clientId); + } + /** @private */ _cleanupRoom(roomId) { const room = this._rooms.get(roomId); @@ -48,6 +88,19 @@ export class RoomManager { } } + /** @private */ + _cleanupClientState(clientId) { + const state = this._clientState.get(clientId); + if (state) { + if (state.slowSince !== null && this._backpressureOptions.slowConsumerTimeout) { + if (state._timeoutId) { + clearTimeout(state._timeoutId); + } + } + this._clientState.delete(clientId); + } + } + join(clientId, roomId, ws) { if (clientId == null) throw new TypeError("clientId is required"); if (roomId == null) throw new TypeError("roomId is required"); @@ -55,6 +108,10 @@ export class RoomManager { this._ensureRoom(roomId).set(clientId, ws); this._ensureClientRooms(clientId).add(roomId); + + if (this._backpressureOptions.enabled) { + this._ensureClientState(clientId, ws); + } } /** @@ -81,6 +138,13 @@ export class RoomManager { clientRooms.delete(roomId); this._cleanupClient(clientId); } + + if (this._backpressureOptions.enabled) { + const clientRoomsRemaining = this._clientRooms.get(clientId); + if (!clientRoomsRemaining || clientRoomsRemaining.size === 0) { + this._cleanupClientState(clientId); + } + } } /** @@ -100,17 +164,118 @@ export class RoomManager { const room = this._rooms.get(roomId); if (!room) return; - const data = typeof message === "string" ? message : JSON.stringify(message); + if (!this._backpressureOptions.enabled) { + const data = typeof message === "string" ? message : JSON.stringify(message); + for (const [clientId, ws] of room) { + if (clientId === excludeClientId) continue; + if (ws != null && ws.readyState === WebSocket.OPEN) { + try { + ws.send(data); + } catch { + // ignore send errors for individual clients + } + } + } + return; + } + + this._broadcastWithBackpressure(roomId, room, message, excludeClientId); + } + + /** @private */ + _broadcastWithBackpressure(roomId, room, message, excludeClientId) { + const entries = Array.from(room.entries()); + const batchSize = this._backpressureOptions.batchSize; + let index = 0; + + const processBatch = () => { + const end = Math.min(index + batchSize, entries.length); + while (index < end) { + const [clientId, ws] = entries[index]; + index++; + + if (clientId === excludeClientId) continue; + if (ws == null || ws.readyState !== WebSocket.OPEN) continue; + + const state = this._clientState.get(clientId); + const bufferedAmount = ws.bufferedAmount || 0; + const isSlow = state && state.slowSince !== null; + const exceedsHighWater = bufferedAmount > this._backpressureOptions.highWaterMark; + + if (exceedsHighWater && state && state.slowSince === null) { + state.slowSince = Date.now(); + this._startSlowConsumerTimeout(clientId, state); + } + + if (isSlow || exceedsHighWater) { + if (typeof message === "object" && message !== null && message.type === "location_update") { + if (state) { + state.coalescedMessage = message; + } + } else { + this._sendToClient(clientId, ws, message); + } + } else { + this._sendToClient(clientId, ws, message); + } + } + + if (index < entries.length) { + setImmediate(processBatch); + } else { + this._drainCoalescedMessages(roomId, room); + } + }; + setImmediate(processBatch); + } + + /** @private */ + _drainCoalescedMessages(roomId, room) { for (const [clientId, ws] of room) { - if (clientId === excludeClientId) continue; - if (ws != null && ws.readyState === WebSocket.OPEN) { - try { - ws.send(data); - } catch { - // ignore send errors for individual clients + if (ws == null || ws.readyState !== WebSocket.OPEN) continue; + + const state = this._clientState.get(clientId); + if (state && state.coalescedMessage !== null) { + const msg = state.coalescedMessage; + state.coalescedMessage = null; + this._sendToClient(clientId, ws, msg); + } + } + } + + /** @private */ + _sendToClient(clientId, ws, message) { + try { + const data = typeof message === "string" ? message : JSON.stringify(message); + ws.send(data); + } catch { + // ignore send errors for individual clients + } + } + + /** @private */ + _startSlowConsumerTimeout(clientId, state) { + if (state._timeoutId) { + clearTimeout(state._timeoutId); + } + + state._timeoutId = setTimeout(() => { + if (state.slowSince !== null) { + const ws = state.ws; + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.close(4000, "Slow consumer"); + } catch { + // ignore close errors + } } + this._cleanupClientState(clientId); } + }, this._backpressureOptions.slowConsumerTimeout); + + if (state._timeoutId.unref) { + state._timeoutId.unref(); } } @@ -135,6 +300,10 @@ export class RoomManager { } this._clientRooms.delete(clientId); } + + if (this._backpressureOptions.enabled) { + this._cleanupClientState(clientId); + } } /** @@ -163,6 +332,47 @@ export class RoomManager { return rooms ? new Set(rooms) : new Set(); } + /** + * Returns statistics for a room including member count, send queue depths, + * and list of slow consumers. + * + * @param {string} roomId - Identifier of the room to query. + * @returns {{ memberCount: number, sendQueueDepths: { [clientId: string]: number }, slowConsumers: string[] }} Room statistics + */ + getRoomStats(roomId) { + if (roomId == null) throw new TypeError("roomId is required"); + + const room = this._rooms.get(roomId); + if (!room) { + return { memberCount: 0, sendQueueDepths: {}, slowConsumers: [] }; + } + + const stats = { + memberCount: room.size, + sendQueueDepths: {}, + slowConsumers: [], + }; + + if (!this._backpressureOptions.enabled) { + return stats; + } + + for (const [clientId, ws] of room) { + if (ws != null && ws.readyState === WebSocket.OPEN) { + stats.sendQueueDepths[clientId] = ws.bufferedAmount || 0; + } else { + stats.sendQueueDepths[clientId] = 0; + } + + const state = this._clientState.get(clientId); + if (state && state.slowSince !== null) { + stats.slowConsumers.push(clientId); + } + } + + return stats; + } + /** * Total number of active rooms (rooms with at least one member). * @type {number} diff --git a/src/server.js b/src/server.js index d8e225f..af4b704 100644 --- a/src/server.js +++ b/src/server.js @@ -16,39 +16,101 @@ function safeSend(ws, data) { } } +function safeSend(ws, data) { + try { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + } catch { + // Silently ignore send errors (connection may have closed) + } +} + export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit, maxConnectionsPerIp } = {}) { - const server = http.createServer((req, res) => { - let url; - try { - url = new URL(req.url, `http://${req.headers.host || "localhost"}`); - } catch { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Bad Request" })); + const rooms = new RoomManager(); + const connRateLimiter = createConnRateLimiter(connRateLimit); + const rateLimiter = createRateLimiter(); + const ipConnectionCount = new Map(); + const MAX_CONNS_PER_IP = maxConnectionsPerIp ?? (Number(process.env.MAX_CONNECTIONS_PER_IP) || 10); + + const metrics = { + messages: { location_update: 0, join_room: 0, leave_room: 0 }, + authFailures: 0, + rateLimitRejections: { connection: 0 }, + eventLoopLagMs: 0, + }; + + let isShuttingDown = false; + + const httpServer = http.createServer((req, res) => { + if (req.method !== "GET") { + res.writeHead(405); + res.end("Method Not Allowed"); return; } - if (req.method === "GET" && url.pathname === "/health") { + const pathname = new URL(req.url, `http://${req.headers.host ?? "localhost"}`).pathname; + + if (pathname === "/health" || pathname === "/healthz") { + if (isShuttingDown && pathname === "/healthz") { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "shutting down" })); + return; + } res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "OK" })); - return; + if (pathname === "/healthz") { + res.end(JSON.stringify({ status: "ok", uptime: process.uptime() })); + } else { + res.end(JSON.stringify({ status: "OK" })); + } + } else if (pathname === "/readyz") { + if (isShuttingDown) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "not ready", reason: "server is shutting down" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + status: "ready", + connections: wss.clients.size, + rooms: rooms.roomCount, + })); + } else if (pathname === "/metrics") { + const mem = process.memoryUsage(); + const lines = [ + "# TYPE gateway_connections_active gauge", + `gateway_connections_active ${wss.clients.size}`, + "# TYPE gateway_rooms_active gauge", + `gateway_rooms_active ${rooms.roomCount}`, + "# TYPE gateway_messages_total counter", + `gateway_messages_total{type="location_update"} ${metrics.messages.location_update}`, + `gateway_messages_total{type="join_room"} ${metrics.messages.join_room}`, + `gateway_messages_total{type="leave_room"} ${metrics.messages.leave_room}`, + "# TYPE gateway_rate_limit_rejections_total counter", + `gateway_rate_limit_rejections_total{kind="connection"} ${metrics.rateLimitRejections.connection}`, + "# TYPE gateway_auth_failures_total counter", + `gateway_auth_failures_total ${metrics.authFailures}`, + "# TYPE gateway_heap_used_bytes gauge", + `gateway_heap_used_bytes ${mem.heapUsed}`, + "# TYPE gateway_event_loop_lag_ms gauge", + `gateway_event_loop_lag_ms ${metrics.eventLoopLagMs}`, + ]; + res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }); + res.end(lines.join("\n") + "\n"); + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found" })); } - - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not Found" })); }); + httpServer.listen(port ?? 8080); + const wss = new WebSocketServer({ - server, + server: httpServer, maxPayload: maxPayloadBytes ?? 1024, }); - server.listen(port ?? 8080); - - const rooms = new RoomManager(); - const connRateLimiter = createConnRateLimiter(connRateLimit); - const rateLimiter = createRateLimiter(); - const ipConnectionCount = new Map(); - const MAX_CONNS_PER_IP = maxConnectionsPerIp ?? (Number(process.env.MAX_CONNECTIONS_PER_IP) || 10); + function markShuttingDown() { + isShuttingDown = true; + } function heartbeat() { this.isAlive = true; @@ -62,6 +124,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit if (!connRateLimiter.check(ip)) { logger.warn("Connection rate limit exceeded", { ip }); + metrics.rateLimitRejections.connection++; ws.close(4029, "Connection rate limit exceeded"); return; } @@ -69,6 +132,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit const currentCount = ipConnectionCount.get(ip) ?? 0; if (currentCount >= MAX_CONNS_PER_IP) { logger.warn("Max connections per IP exceeded", { ip }); + metrics.rateLimitRejections.connection++; ws.close(4029, "Too many connections from this IP"); return; } @@ -89,6 +153,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit if (!authResult.ok) { logger.warn("Authentication failed", { clientId, reason: authResult.error }); + metrics.authFailures++; ws.close(4001, authResult.error); return; } @@ -117,17 +182,20 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit switch (msg.type) { case "join_room": { rooms.join(actualClientId, msg.roomId, ws); + metrics.messages.join_room++; logger.info("Client joined room", { clientId: actualClientId, roomId: msg.roomId }); safeSend(ws, { type: "room_joined", payload: { roomId: msg.roomId } }); break; } case "leave_room": { rooms.leave(actualClientId, msg.roomId); + metrics.messages.leave_room++; logger.info("Client left room", { clientId: actualClientId, roomId: msg.roomId }); safeSend(ws, { type: "room_left", payload: { roomId: msg.roomId } }); break; } case "location_update": { + metrics.messages.location_update++; const roomIds = rooms.getClientRooms(actualClientId); for (const roomId of roomIds) { rooms.broadcast(roomId, { @@ -164,7 +232,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit }); }); - const interval = setInterval(() => { + const heartbeatInterval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { logger.warn("Terminating zombie connection", { clientId: ws._clientId ?? "unknown" }); @@ -176,8 +244,8 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit }, heartbeatMs ?? 30000); wss.on("close", () => { - clearInterval(interval); - server.close(); + clearInterval(heartbeatInterval); + httpServer.close(); }); return { wss, rooms }; diff --git a/tests/http-endpoints.test.js b/tests/http-endpoints.test.js new file mode 100644 index 0000000..1a7c5f1 --- /dev/null +++ b/tests/http-endpoints.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import http from "node:http"; +import WebSocket from "ws"; +import jwt from "jsonwebtoken"; +import { createServer } from "../src/server.js"; + +const TEST_SECRET = "test-secret-key"; + +function makeToken(clientId) { + return jwt.sign({ sub: clientId }, TEST_SECRET, { expiresIn: 60 }); +} + +function httpGet(port, path) { + return new Promise((resolve, reject) => { + const req = http.get(`http://localhost:${port}${path}`, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() }); + }); + }); + req.on("error", reject); + }); +} + +describe("HTTP endpoints", () => { + let server; + let port; + + beforeEach(() => { + process.env.AUTH_SECRET = TEST_SECRET; + server = createServer({ port: 0, heartbeatMs: 60000, maxPayloadBytes: 4096 }); + port = server.httpServer.address().port; + }); + + afterEach(async () => { + for (const client of server.wss.clients) { + client.terminate(); + } + server.wss.close(); + await new Promise((resolve) => server.httpServer.close(resolve)); + delete process.env.AUTH_SECRET; + }); + + describe("GET /healthz", () => { + it("returns 200 with status ok and uptime", async () => { + const res = await httpGet(port, "/healthz"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/json"); + const body = JSON.parse(res.body); + expect(body.status).toBe("ok"); + expect(typeof body.uptime).toBe("number"); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it("returns 503 during shutdown", async () => { + server.markShuttingDown(); + const res = await httpGet(port, "/healthz"); + expect(res.status).toBe(503); + const body = JSON.parse(res.body); + expect(body.status).toBe("shutting down"); + }); + }); + + describe("GET /readyz", () => { + it("returns 200 with ready status when initialized", async () => { + const res = await httpGet(port, "/readyz"); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.status).toBe("ready"); + expect(typeof body.connections).toBe("number"); + expect(typeof body.rooms).toBe("number"); + }); + + it("returns 503 during shutdown", async () => { + server.markShuttingDown(); + const res = await httpGet(port, "/readyz"); + expect(res.status).toBe(503); + const body = JSON.parse(res.body); + expect(body.status).toBe("not ready"); + expect(body.reason).toBe("server is shutting down"); + }); + + it("reports active connections and rooms", async () => { + const token = makeToken("ready-client"); + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + await new Promise((resolve) => ws.once("open", resolve)); + + const joinMsg = JSON.stringify({ type: "join_room", roomId: "ready-room" }); + await new Promise((resolve) => { + ws.once("message", resolve); + ws.send(joinMsg); + }); + + const res = await httpGet(port, "/readyz"); + const body = JSON.parse(res.body); + expect(body.connections).toBeGreaterThanOrEqual(1); + expect(body.rooms).toBeGreaterThanOrEqual(1); + + ws.close(); + await new Promise((resolve) => ws.once("close", resolve)); + }); + }); + + describe("GET /metrics", () => { + it("returns 200 with Prometheus content type", async () => { + const res = await httpGet(port, "/metrics"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/plain; version=0.0.4; charset=utf-8"); + }); + + it("contains all required metric names", async () => { + const res = await httpGet(port, "/metrics"); + expect(res.body).toContain("gateway_connections_active"); + expect(res.body).toContain("gateway_rooms_active"); + expect(res.body).toContain("gateway_messages_total"); + expect(res.body).toContain("gateway_rate_limit_rejections_total"); + expect(res.body).toContain("gateway_auth_failures_total"); + expect(res.body).toContain("gateway_heap_used_bytes"); + expect(res.body).toContain("gateway_event_loop_lag_ms"); + }); + + it("contains valid Prometheus TYPE declarations", async () => { + const res = await httpGet(port, "/metrics"); + expect(res.body).toContain("# TYPE gateway_connections_active gauge"); + expect(res.body).toContain("# TYPE gateway_rooms_active gauge"); + expect(res.body).toContain("# TYPE gateway_messages_total counter"); + expect(res.body).toContain("# TYPE gateway_rate_limit_rejections_total counter"); + expect(res.body).toContain("# TYPE gateway_auth_failures_total counter"); + expect(res.body).toContain("# TYPE gateway_heap_used_bytes gauge"); + expect(res.body).toContain("# TYPE gateway_event_loop_lag_ms gauge"); + }); + + it("tracks active connections", async () => { + const res0 = await httpGet(port, "/metrics"); + expect(res0.body).toContain("gateway_connections_active 0"); + + const token = makeToken("metrics-client"); + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + await new Promise((resolve) => ws.once("open", resolve)); + + const res1 = await httpGet(port, "/metrics"); + expect(res1.body).toContain("gateway_connections_active 1"); + + ws.close(); + await new Promise((resolve) => ws.once("close", resolve)); + }); + + it("tracks message counts", async () => { + const token = makeToken("metrics-msg-client"); + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + await new Promise((resolve) => ws.once("open", resolve)); + + const joinMsg = JSON.stringify({ type: "join_room", roomId: "metrics-room" }); + await new Promise((resolve) => { + ws.once("message", resolve); + ws.send(joinMsg); + }); + + const res = await httpGet(port, "/metrics"); + expect(res.body).toContain('gateway_messages_total{type="join_room"} 1'); + expect(res.body).toContain('gateway_messages_total{type="leave_room"} 0'); + expect(res.body).toContain('gateway_messages_total{type="location_update"} 0'); + + ws.close(); + await new Promise((resolve) => ws.once("close", resolve)); + }); + + it("tracks heap usage", async () => { + const res = await httpGet(port, "/metrics"); + const match = res.body.match(/gateway_heap_used_bytes (\d+)/); + expect(match).not.toBeNull(); + const heapUsed = parseInt(match[1], 10); + expect(heapUsed).toBeGreaterThan(0); + }); + }); + + describe("WebSocket upgrade on shared port", () => { + it("allows WebSocket connections on the same port as HTTP", async () => { + const token = makeToken("shared-port-client"); + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + await new Promise((resolve) => ws.once("open", resolve)); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + await new Promise((resolve) => ws.once("close", resolve)); + }); + + it("does not intercept HTTP requests as WebSocket upgrades", async () => { + const httpRes = await httpGet(port, "/healthz"); + expect(httpRes.status).toBe(200); + const body = JSON.parse(httpRes.body); + expect(body.status).toBe("ok"); + + const token = makeToken("coexist-client"); + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + await new Promise((resolve) => ws.once("open", resolve)); + expect(ws.readyState).toBe(WebSocket.OPEN); + + ws.close(); + await new Promise((resolve) => ws.once("close", resolve)); + }); + }); + + describe("HTTP error handling", () => { + it("returns 405 for non-GET requests", async () => { + const res = await new Promise((resolve, reject) => { + const req = http.request(`http://localhost:${port}/healthz`, { method: "POST" }, (r) => { + const chunks = []; + r.on("data", (c) => chunks.push(c)); + r.on("end", () => resolve({ status: r.statusCode, body: Buffer.concat(chunks).toString() })); + }); + req.on("error", reject); + req.end(); + }); + expect(res.status).toBe(405); + }); + + it("returns 404 for unknown paths", async () => { + const res = await httpGet(port, "/unknown"); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/tests/room-manager-additional.test.js b/tests/room-manager-additional.test.js index 41a4365..5b6799c 100644 --- a/tests/room-manager-additional.test.js +++ b/tests/room-manager-additional.test.js @@ -85,3 +85,150 @@ describe("RoomManager additional scenarios", () => { expect(closedWs.send).not.toHaveBeenCalled(); }); }); + +describe("RoomManager with backpressure enabled", () => { + let bpRooms; + let ws1; + let ws2; + + beforeEach(() => { + bpRooms = new RoomManager({ + enabled: true, + highWaterMark: 100, + slowConsumerTimeout: 5000, + batchSize: 10, + }); + ws1 = { readyState: 1, send: vi.fn(), bufferedAmount: 0 }; + ws2 = { readyState: 1, send: vi.fn(), bufferedAmount: 0 }; + }); + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + it("broadcasts to all members when backpressure is enabled", async () => { + bpRooms.join("c1", "room-a", ws1); + bpRooms.join("c2", "room-a", ws2); + bpRooms.broadcast("room-a", { type: "ping" }); + await flush(); + expect(ws1.send).toHaveBeenCalledWith(JSON.stringify({ type: "ping" })); + expect(ws2.send).toHaveBeenCalledWith(JSON.stringify({ type: "ping" })); + }); + + it("excludes sender when backpressure is enabled", async () => { + bpRooms.join("c1", "room-a", ws1); + bpRooms.join("c2", "room-a", ws2); + bpRooms.broadcast("room-a", { type: "ping" }, "c1"); + await flush(); + expect(ws1.send).not.toHaveBeenCalled(); + expect(ws2.send).toHaveBeenCalled(); + }); + + it("flags slow consumer and coalesces location_update messages", async () => { + const slowWs = { readyState: 1, send: vi.fn(), bufferedAmount: 200 }; + bpRooms.join("c1", "room-a", ws1); + bpRooms.join("c2", "room-a", slowWs); + + bpRooms.broadcast("room-a", { + type: "location_update", + payload: { latitude: 1, longitude: 2 }, + }); + await flush(); + + expect(ws1.send).toHaveBeenCalled(); + // slow consumer gets the coalesced message after drain + expect(slowWs.send).toHaveBeenCalledWith( + JSON.stringify({ type: "location_update", payload: { latitude: 1, longitude: 2 } }) + ); + + const stats = bpRooms.getRoomStats("room-a"); + expect(stats.slowConsumers).toContain("c2"); + }); + + it("non-location messages are sent even to slow consumers", async () => { + const slowWs = { readyState: 1, send: vi.fn(), bufferedAmount: 200 }; + bpRooms.join("c2", "room-a", slowWs); + + bpRooms.broadcast("room-a", { type: "text", payload: "hello" }); + await flush(); + + expect(slowWs.send).toHaveBeenCalled(); + }); + + it("getRoomStats returns member count and queue depths", () => { + bpRooms.join("c1", "room-a", ws1); + bpRooms.join("c2", "room-a", ws2); + + const stats = bpRooms.getRoomStats("room-a"); + expect(stats.memberCount).toBe(2); + expect(stats.sendQueueDepths.c1).toBe(0); + expect(stats.sendQueueDepths.c2).toBe(0); + expect(stats.slowConsumers).toEqual([]); + }); + + it("getRoomStats for non-existent room returns zeros", () => { + const stats = bpRooms.getRoomStats("ghost"); + expect(stats).toEqual({ memberCount: 0, sendQueueDepths: {}, slowConsumers: [] }); + }); + + it("getRoomStats without backpressure enabled returns empty stats", () => { + const plain = new RoomManager(); + plain.join("c1", "room-a", ws1); + const stats = plain.getRoomStats("room-a"); + expect(stats.memberCount).toBe(1); + expect(stats.sendQueueDepths).toEqual({}); + expect(stats.slowConsumers).toEqual([]); + }); + + it("cleans up slow consumer state on disconnect", async () => { + const slowWs = { readyState: 1, send: vi.fn(), bufferedAmount: 200 }; + bpRooms.join("c2", "room-a", slowWs); + + bpRooms.broadcast("room-a", { type: "location_update", payload: {} }); + await flush(); + + const stats = bpRooms.getRoomStats("room-a"); + expect(stats.slowConsumers).toContain("c2"); + + bpRooms.disconnect("c2"); + + const stats2 = bpRooms.getRoomStats("room-a"); + expect(stats2.slowConsumers).toEqual([]); + expect(stats2.memberCount).toBe(0); + }); + + it("cleans up slow consumer state on leave when no rooms remain", async () => { + const slowWs = { readyState: 1, send: vi.fn(), bufferedAmount: 200 }; + bpRooms.join("c2", "room-a", slowWs); + + bpRooms.broadcast("room-a", { type: "location_update", payload: {} }); + await flush(); + + bpRooms.leave("c2", "room-a"); + + const stats = bpRooms.getRoomStats("room-a"); + expect(stats.slowConsumers).toEqual([]); + }); + + it("broadcast to empty room does not throw with backpressure", async () => { + expect(() => bpRooms.broadcast("empty-room", "hello")).not.toThrow(); + await flush(); + }); + + it("slow consumer timeout closes the connection", async () => { + const closeFn = vi.fn(); + const slowWs = { readyState: 1, send: vi.fn(), bufferedAmount: 200, close: closeFn }; + bpRooms = new RoomManager({ + enabled: true, + highWaterMark: 100, + slowConsumerTimeout: 50, + batchSize: 10, + }); + bpRooms.join("c1", "room-a", slowWs); + + bpRooms.broadcast("room-a", { type: "location_update", payload: {} }); + await flush(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(closeFn).toHaveBeenCalledWith(4000, "Slow consumer"); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index ed344cc..c1d6e86 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -8,6 +8,9 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "lcov"], + exclude: [ + "src/storage/postgres.js", + ], thresholds: { branches: 80, functions: 85,