diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cf8f23..821d091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: - run: npm audit - run: npm run format:check - run: npm run lint - - run: npm run typecheck - run: npm run build + - run: npm run typecheck - run: npm run lint:pkg - run: npm run test:coverage - run: npm run test:mutation diff --git a/package-lock.json b/package-lock.json index fd09cc0..09f5fcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1490,6 +1490,96 @@ } } }, + "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/@isaacs/cliui/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/@isaacs/cliui/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/@isaacs/cliui/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/@isaacs/cliui/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/@isaacs/cliui/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/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1679,6 +1769,13 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -2335,6 +2432,17 @@ "node": "^20.19.0 || >=22.12.0" } }, + "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/@publint/pack": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.4.tgz", @@ -2644,6 +2752,10 @@ "resolved": "packages/http", "link": true }, + "node_modules/@script-development/fs-loading": { + "resolved": "packages/loading", + "link": true + }, "node_modules/@script-development/fs-storage": { "resolved": "packages/storage", "link": true @@ -3175,6 +3287,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -3749,6 +3882,17 @@ "node": ">=14" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3933,6 +4077,65 @@ "node": ">= 0.4" } }, + "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/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/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/electron-to-chromium": { "version": "1.5.329", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", @@ -4282,6 +4485,23 @@ } } }, + "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/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4424,6 +4644,28 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "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": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -4437,6 +4679,39 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "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/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -4630,6 +4905,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-buffer": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", @@ -4812,6 +5094,54 @@ "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-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", @@ -5411,6 +5741,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -5529,6 +5869,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -5747,6 +6103,13 @@ "node": ">=6" } }, + "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/package-manager-detector": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", @@ -5814,6 +6177,30 @@ "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/path-scurry/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/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5932,6 +6319,13 @@ "node": ">=0.4.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -6575,6 +6969,29 @@ "node": ">=8" } }, + "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/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/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -6595,6 +7012,30 @@ "node": ">=8" } }, + "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-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -7349,6 +7790,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -7455,6 +7903,25 @@ "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/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -7552,6 +8019,22 @@ "axios-mock-adapter": "^2.1.0" } }, + "packages/loading": { + "name": "@script-development/fs-loading", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@script-development/fs-http": "^0.1.0", + "@vue/test-utils": "^2.4.6", + "axios": "1.14.0", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "@script-development/fs-http": "^0.1.0", + "vue": "^3.5.0" + } + }, "packages/storage": { "name": "@script-development/fs-storage", "version": "0.1.0", diff --git a/packages/loading/package.json b/packages/loading/package.json new file mode 100644 index 0000000..ef48b26 --- /dev/null +++ b/packages/loading/package.json @@ -0,0 +1,52 @@ +{ + "name": "@script-development/fs-loading", + "version": "0.1.0", + "description": "Reactive loading state service with counter-based tracking and HTTP middleware for fs-http", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/script-development/fs-packages.git", + "directory": "packages/loading" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@script-development/fs-http": "^0.1.0", + "@vue/test-utils": "^2.4.6", + "axios": "1.14.0", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "@script-development/fs-http": "^0.1.0", + "vue": "^3.5.0" + } +} diff --git a/packages/loading/src/index.ts b/packages/loading/src/index.ts new file mode 100644 index 0000000..10a18cf --- /dev/null +++ b/packages/loading/src/index.ts @@ -0,0 +1,3 @@ +export { createLoadingService } from "./loading"; +export { registerLoadingMiddleware } from "./middleware"; +export type { LoadingService, LoadingMiddlewareOptions, LoadingMiddlewareResult } from "./types"; diff --git a/packages/loading/src/loading.ts b/packages/loading/src/loading.ts new file mode 100644 index 0000000..bb46318 --- /dev/null +++ b/packages/loading/src/loading.ts @@ -0,0 +1,29 @@ +import type { LoadingService } from "./types"; + +import { computed, readonly, ref, watch } from "vue"; + +export const createLoadingService = (): LoadingService => { + const activeCount = ref(0); + const isLoading = computed(() => activeCount.value > 0); + + return { + isLoading, + activeCount: readonly(activeCount), + startLoading: () => { + activeCount.value++; + }, + stopLoading: () => { + activeCount.value = Math.max(0, activeCount.value - 1); + }, + ensureLoadingFinished: () => { + if (!isLoading.value) return Promise.resolve(); + + return new Promise((resolve) => { + const unwatch = watch(isLoading, () => { + unwatch(); + resolve(); + }); + }); + }, + }; +}; diff --git a/packages/loading/src/middleware.ts b/packages/loading/src/middleware.ts new file mode 100644 index 0000000..39c61fc --- /dev/null +++ b/packages/loading/src/middleware.ts @@ -0,0 +1,62 @@ +import type { HttpService } from "@script-development/fs-http"; +import type { InternalAxiosRequestConfig } from "axios"; + +import type { LoadingMiddlewareOptions, LoadingMiddlewareResult, LoadingService } from "./types"; + +export const registerLoadingMiddleware = ( + httpService: HttpService, + loadingService: LoadingService, + options: LoadingMiddlewareOptions = {}, +): LoadingMiddlewareResult => { + const { timeoutMs = 30000 } = options; + + const requestTimeouts = new Map>(); + const completedRequests = new WeakSet(); + + const stopLoadingForRequest = (config: InternalAxiosRequestConfig): void => { + if (completedRequests.has(config)) return; + completedRequests.add(config); + + const timeout = requestTimeouts.get(config); + if (timeout) { + clearTimeout(timeout); + requestTimeouts.delete(config); + } + + loadingService.stopLoading(); + }; + + const unregisterRequest = httpService.registerRequestMiddleware((config) => { + loadingService.startLoading(); + + if (timeoutMs > 0) { + const timeout = setTimeout(() => { + stopLoadingForRequest(config); + }, timeoutMs); + requestTimeouts.set(config, timeout); + } + }); + + const unregisterResponse = httpService.registerResponseMiddleware((response) => { + stopLoadingForRequest(response.config); + }); + + const unregisterError = httpService.registerResponseErrorMiddleware((error) => { + if (error.config) { + stopLoadingForRequest(error.config); + } + }); + + const unregister = (): void => { + unregisterRequest(); + unregisterResponse(); + unregisterError(); + + for (const timeout of requestTimeouts.values()) { + clearTimeout(timeout); + } + requestTimeouts.clear(); + }; + + return { unregister }; +}; diff --git a/packages/loading/src/types.ts b/packages/loading/src/types.ts new file mode 100644 index 0000000..5220241 --- /dev/null +++ b/packages/loading/src/types.ts @@ -0,0 +1,22 @@ +import type { ComputedRef, DeepReadonly, Ref } from "vue"; + +export type LoadingService = { + isLoading: ComputedRef; + activeCount: DeepReadonly>; + startLoading: () => void; + stopLoading: () => void; + ensureLoadingFinished: () => Promise; +}; + +export type LoadingMiddlewareOptions = { + /** + * Timeout in milliseconds after which a request is considered stuck + * and loading state is auto-decremented. Set to 0 to disable. + * @default 30000 (30 seconds) + */ + timeoutMs?: number; +}; + +export type LoadingMiddlewareResult = { + unregister: () => void; +}; diff --git a/packages/loading/tests/loading.spec.ts b/packages/loading/tests/loading.spec.ts new file mode 100644 index 0000000..8f87773 --- /dev/null +++ b/packages/loading/tests/loading.spec.ts @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { createLoadingService } from "../src"; +import { flushPromises } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; + +describe("createLoadingService", () => { + it("should initialize with isLoading as false", () => { + const service = createLoadingService(); + + expect(service.isLoading.value).toBe(false); + }); + + it("should set isLoading to true when startLoading is called", () => { + const service = createLoadingService(); + + service.startLoading(); + + expect(service.isLoading.value).toBe(true); + }); + + it("should set isLoading to false when stopLoading is called", () => { + const service = createLoadingService(); + service.startLoading(); + + service.stopLoading(); + + expect(service.isLoading.value).toBe(false); + }); + + it("should resolve immediately from ensureLoadingFinished when not loading", async () => { + const service = createLoadingService(); + + await service.ensureLoadingFinished(); + + expect(service.isLoading.value).toBe(false); + }); + + it("should wait and resolve from ensureLoadingFinished when loading finishes", async () => { + const service = createLoadingService(); + service.startLoading(); + let resolved = false; + + const promise = service.ensureLoadingFinished().then(() => { + resolved = true; + }); + + await flushPromises(); + expect(resolved).toBe(false); + + service.stopLoading(); + await flushPromises(); + await promise; + + expect(resolved).toBe(true); + }); + + it("should handle multiple calls to ensureLoadingFinished", async () => { + const service = createLoadingService(); + service.startLoading(); + let resolved1 = false; + let resolved2 = false; + + const promise1 = service.ensureLoadingFinished().then(() => { + resolved1 = true; + }); + const promise2 = service.ensureLoadingFinished().then(() => { + resolved2 = true; + }); + + await flushPromises(); + expect(resolved1).toBe(false); + expect(resolved2).toBe(false); + + service.stopLoading(); + await Promise.all([promise1, promise2]); + + expect(resolved1).toBe(true); + expect(resolved2).toBe(true); + }); + + it("should track activeCount", () => { + const service = createLoadingService(); + + expect(service.activeCount.value).toBe(0); + + service.startLoading(); + expect(service.activeCount.value).toBe(1); + + service.startLoading(); + expect(service.activeCount.value).toBe(2); + + service.stopLoading(); + expect(service.activeCount.value).toBe(1); + + service.stopLoading(); + expect(service.activeCount.value).toBe(0); + }); + + it("should stay loading while any request is active", () => { + const service = createLoadingService(); + + service.startLoading(); + service.startLoading(); + + expect(service.isLoading.value).toBe(true); + expect(service.activeCount.value).toBe(2); + + service.stopLoading(); + + expect(service.isLoading.value).toBe(true); + expect(service.activeCount.value).toBe(1); + + service.stopLoading(); + + expect(service.isLoading.value).toBe(false); + expect(service.activeCount.value).toBe(0); + }); + + it("should not allow activeCount to go below zero", () => { + const service = createLoadingService(); + + service.stopLoading(); + service.stopLoading(); + + expect(service.activeCount.value).toBe(0); + expect(service.isLoading.value).toBe(false); + }); + + it("should wait for all requests to finish in ensureLoadingFinished", async () => { + const service = createLoadingService(); + service.startLoading(); + service.startLoading(); + let resolved = false; + + const promise = service.ensureLoadingFinished().then(() => { + resolved = true; + }); + + await flushPromises(); + service.stopLoading(); + await flushPromises(); + expect(resolved).toBe(false); + + service.stopLoading(); + await flushPromises(); + await promise; + expect(resolved).toBe(true); + }); +}); diff --git a/packages/loading/tests/middleware.spec.ts b/packages/loading/tests/middleware.spec.ts new file mode 100644 index 0000000..9ef0370 --- /dev/null +++ b/packages/loading/tests/middleware.spec.ts @@ -0,0 +1,294 @@ +// @vitest-environment jsdom +import type { AxiosResponseError, HttpService } from "@script-development/fs-http"; +import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios"; + +import { createLoadingService, registerLoadingMiddleware } from "../src"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type RequestMiddleware = (config: InternalAxiosRequestConfig) => void; +type ResponseMiddleware = (response: AxiosResponse) => void; +type ErrorMiddleware = (error: AxiosError) => void; + +const createMockHttpService = () => { + const requestMiddlewares: RequestMiddleware[] = []; + const responseMiddlewares: ResponseMiddleware[] = []; + const errorMiddlewares: ErrorMiddleware[] = []; + + const triggerRequest = (config: Partial = {}) => { + const fullConfig = { url: "/test", ...config } as InternalAxiosRequestConfig; + for (const middleware of requestMiddlewares) { + middleware(fullConfig); + } + return fullConfig; + }; + + const triggerResponse = (config: InternalAxiosRequestConfig) => { + const response = { config, data: {}, status: 200 } as AxiosResponse; + for (const middleware of responseMiddlewares) { + middleware(response); + } + }; + + const triggerError = (config?: InternalAxiosRequestConfig) => { + const error = { + config, + message: "Error", + isAxiosError: true, + } as AxiosError; + for (const middleware of errorMiddlewares) { + middleware(error); + } + }; + + const httpService: HttpService = { + getRequest: vi.fn(), + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + downloadRequest: vi.fn(), + previewRequest: vi.fn(), + streamRequest: vi.fn(), + registerRequestMiddleware: vi.fn((fn: RequestMiddleware) => { + requestMiddlewares.push(fn); + return () => { + const index = requestMiddlewares.indexOf(fn); + if (index > -1) requestMiddlewares.splice(index, 1); + }; + }), + registerResponseMiddleware: vi.fn((fn: ResponseMiddleware) => { + responseMiddlewares.push(fn); + return () => { + const index = responseMiddlewares.indexOf(fn); + if (index > -1) responseMiddlewares.splice(index, 1); + }; + }), + registerResponseErrorMiddleware: vi.fn((fn: ErrorMiddleware) => { + errorMiddlewares.push(fn); + return () => { + const index = errorMiddlewares.indexOf(fn); + if (index > -1) errorMiddlewares.splice(index, 1); + }; + }), + }; + + return { httpService, triggerRequest, triggerResponse, triggerError }; +}; + +describe("registerLoadingMiddleware", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should register all three middleware types", () => { + const { httpService } = createMockHttpService(); + const loadingService = createLoadingService(); + + registerLoadingMiddleware(httpService, loadingService); + + expect(httpService.registerRequestMiddleware).toHaveBeenCalledOnce(); + expect(httpService.registerResponseMiddleware).toHaveBeenCalledOnce(); + expect(httpService.registerResponseErrorMiddleware).toHaveBeenCalledOnce(); + }); + + it("should increment loading on request start", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + expect(loadingService.activeCount.value).toBe(0); + + triggerRequest(); + + expect(loadingService.activeCount.value).toBe(1); + expect(loadingService.isLoading.value).toBe(true); + }); + + it("should decrement loading on successful response", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + const config = triggerRequest(); + triggerResponse(config); + + expect(loadingService.activeCount.value).toBe(0); + expect(loadingService.isLoading.value).toBe(false); + }); + + it("should decrement loading on error response", () => { + const { httpService, triggerRequest, triggerError } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + const config = triggerRequest(); + triggerError(config); + + expect(loadingService.activeCount.value).toBe(0); + expect(loadingService.isLoading.value).toBe(false); + }); + + it("should track multiple concurrent requests", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + const config1 = triggerRequest({ url: "/users" }); + const config2 = triggerRequest({ url: "/posts" }); + + expect(loadingService.activeCount.value).toBe(2); + + triggerResponse(config1); + expect(loadingService.activeCount.value).toBe(1); + expect(loadingService.isLoading.value).toBe(true); + + triggerResponse(config2); + expect(loadingService.activeCount.value).toBe(0); + expect(loadingService.isLoading.value).toBe(false); + }); + + it("should auto-decrement loading after timeout", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService, { timeoutMs: 5000 }); + + triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + vi.advanceTimersByTime(5000); + + expect(loadingService.activeCount.value).toBe(0); + expect(loadingService.isLoading.value).toBe(false); + }); + + it("should not auto-decrement if response arrives before timeout", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService, { timeoutMs: 5000 }); + + const config = triggerRequest(); + triggerResponse(config); + + vi.advanceTimersByTime(5000); + + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should not double-decrement if timeout fires after response", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService, { timeoutMs: 5000 }); + + const config1 = triggerRequest({ url: "/users" }); + triggerRequest({ url: "/posts" }); + expect(loadingService.activeCount.value).toBe(2); + + triggerResponse(config1); + expect(loadingService.activeCount.value).toBe(1); + + vi.advanceTimersByTime(5000); + + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should disable timeout when timeoutMs is 0", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService, { timeoutMs: 0 }); + + triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + vi.advanceTimersByTime(60000); + + expect(loadingService.activeCount.value).toBe(1); + }); + + it("should handle response when timeout is disabled", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService, { timeoutMs: 0 }); + + const config = triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + triggerResponse(config); + + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should ignore duplicate response for same request", () => { + const { httpService, triggerRequest, triggerResponse } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + const config = triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + triggerResponse(config); + expect(loadingService.activeCount.value).toBe(0); + + triggerResponse(config); + + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should use default 30 second timeout", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + triggerRequest(); + + vi.advanceTimersByTime(29999); + expect(loadingService.activeCount.value).toBe(1); + + vi.advanceTimersByTime(1); + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should stop tracking after unregister is called", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + const { unregister } = registerLoadingMiddleware(httpService, loadingService); + + unregister(); + triggerRequest(); + + expect(loadingService.activeCount.value).toBe(0); + }); + + it("should clear pending timeouts on unregister", () => { + const { httpService, triggerRequest } = createMockHttpService(); + const loadingService = createLoadingService(); + const { unregister } = registerLoadingMiddleware(httpService, loadingService, { + timeoutMs: 5000, + }); + + triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + unregister(); + + vi.advanceTimersByTime(5000); + + expect(loadingService.activeCount.value).toBe(1); + }); + + it("should handle error without config gracefully", () => { + const { httpService, triggerRequest, triggerError } = createMockHttpService(); + const loadingService = createLoadingService(); + registerLoadingMiddleware(httpService, loadingService); + + triggerRequest(); + expect(loadingService.activeCount.value).toBe(1); + + triggerError(undefined); + + expect(loadingService.activeCount.value).toBe(1); + }); +}); diff --git a/packages/loading/tsconfig.json b/packages/loading/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/loading/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/loading/tsdown.config.ts b/packages/loading/tsdown.config.ts new file mode 100644 index 0000000..aa2e8b8 --- /dev/null +++ b/packages/loading/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/loading/vitest.config.ts b/packages/loading/vitest.config.ts new file mode 100644 index 0000000..499b961 --- /dev/null +++ b/packages/loading/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "loading", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +});