diff --git a/package.json b/package.json index c859fe3..974b40e 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,34 @@ "files": [ "dist" ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, "scripts": { "build": "tsup", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "ws": "^8.19.0", "zod": "^3.24.0" }, + "peerDependencies": { + "ws": "^8.19.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } + }, "devDependencies": { "@types/node": "^22.19.0", "@types/ws": "^8.5.13", @@ -31,7 +47,8 @@ "eslint-config-prettier": "^9.1.0", "prettier": "^3.8.0", "tsup": "^8.5.0", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "vitest": "^4.1.0" }, "engines": { "node": ">=18.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26ebaf1..097478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,13 +38,25 @@ importers: version: 3.8.1 tsup: specifier: ^8.5.0 - version: 8.5.1(typescript@5.9.3) + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) typescript: specifier: ^5.9.0 version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@22.19.11)(vite@8.0.1(@types/node@22.19.11)(esbuild@0.27.3)) packages: + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -268,6 +280,104 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.10': + resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -393,6 +503,18 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -464,6 +586,35 @@ packages: resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -487,6 +638,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -510,6 +665,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -539,6 +698,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -555,6 +717,13 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -608,10 +777,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -716,6 +892,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -753,6 +999,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -760,6 +1011,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -819,6 +1073,10 @@ packages: yaml: optional: true + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -844,6 +1102,11 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.0.0-rc.10: + resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -862,10 +1125,23 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -886,13 +1162,24 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -906,6 +1193,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -943,11 +1233,94 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.0.1: + resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -973,6 +1346,22 @@ packages: snapshots: + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -1122,6 +1511,64 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.120.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.10': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -1197,6 +1644,20 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1300,6 +1761,47 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@8.0.1(@types/node@22.19.11)(esbuild@0.27.3))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.1(@types/node@22.19.11)(esbuild@0.27.3) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -1321,6 +1823,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} brace-expansion@1.1.12: @@ -1341,6 +1845,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1364,6 +1870,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1376,6 +1884,10 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + + es-module-lexer@2.0.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1475,8 +1987,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1560,6 +2078,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -1599,10 +2166,14 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + natural-compare@1.4.0: {} object-assign@4.1.1: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1642,9 +2213,17 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1: + postcss-load-config@6.0.1(postcss@8.5.8): dependencies: lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} @@ -1658,6 +2237,27 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.0.0-rc.10: + dependencies: + '@oxc-project/types': 0.120.0 + '@rolldown/pluginutils': 1.0.0-rc.10 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-x64': 1.0.0-rc.10 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -1697,8 +2297,16 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + strip-json-comments@3.1.1: {} sucrase@3.35.1: @@ -1723,13 +2331,19 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + tree-kill@1.2.2: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -1738,7 +2352,10 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(typescript@5.9.3): + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -1749,7 +2366,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(postcss@8.5.8) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -1758,6 +2375,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -1779,10 +2397,54 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.0.1(@types/node@22.19.11)(esbuild@0.27.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.5.8 + rolldown: 1.0.0-rc.10 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + esbuild: 0.27.3 + fsevents: 2.3.3 + + vitest@4.1.0(@types/node@22.19.11)(vite@8.0.1(@types/node@22.19.11)(esbuild@0.27.3)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@22.19.11)(esbuild@0.27.3)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.1(@types/node@22.19.11)(esbuild@0.27.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.11 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} ws@8.19.0: {} diff --git a/src/WebSocketClient.ts b/src/WebSocketClient.ts index 9023063..44a1bd1 100644 --- a/src/WebSocketClient.ts +++ b/src/WebSocketClient.ts @@ -1,6 +1,7 @@ -import { EventEmitter } from "events"; -import WebSocket from "ws"; import { z } from "zod"; +import { Emitter } from "./platform/Emitter"; +import { getPlatformAdapter } from "./platform/index"; +import type { PlatformAdapter } from "./platform/types"; import { WebSocketConnection } from "./connection/WebSocketConnection"; import { WebSocketRouter } from "./router/WebSocketRouter"; import { Logger } from "./logger/Logger"; @@ -41,13 +42,15 @@ import { MessageHandler, HandlerError } from "./router/types"; * client.connect(); * ``` */ -export class WebSocketClient extends EventEmitter { +export class WebSocketClient extends Emitter { private connection: WebSocketConnection; private router: WebSocketRouter; private logger: Logger; + private adapter: PlatformAdapter; constructor(options: WebSocketClientOptions) { super(); + this.adapter = getPlatformAdapter(); this.logger = new Logger(options.logger); this.connection = new WebSocketConnection(options, this.logger); this.router = new WebSocketRouter(this.logger); @@ -99,13 +102,15 @@ export class WebSocketClient extends EventEmitter { /** * Sends data through the WebSocket. Objects are JSON-serialized automatically. * - * @param data - Data to send. Objects/arrays are JSON.stringified; strings and Buffers are sent as-is. + * @param data - Data to send. Objects/arrays are JSON.stringified; strings and binary data are sent as-is. * @returns `true` if sent, `false` if the connection is not open. */ send(data: unknown): boolean { const serialized = - typeof data === "string" || Buffer.isBuffer(data) ? data : JSON.stringify(data); - return this.connection.send(serialized); + typeof data === "string" || data instanceof Uint8Array || data instanceof ArrayBuffer + ? data + : JSON.stringify(data); + return this.connection.send(serialized as string | ArrayBuffer | Uint8Array); } /** @@ -148,8 +153,8 @@ export class WebSocketClient extends EventEmitter { this.router.on("error", (handlerError: HandlerError) => this.emit("error", handlerError)); // Connection messages → router - this.connection.on("message", (data: WebSocket.Data) => { - const raw = this.toRawString(data); + this.connection.on("message", (data: unknown) => { + const raw = this.adapter.dataToString(data); if (raw !== null) { const sendFn = (payload: unknown): boolean => this.send(payload); const info = this.connection.getConnectionInfo(); @@ -160,20 +165,4 @@ export class WebSocketClient extends EventEmitter { } }); } - - private toRawString(data: WebSocket.Data): string | null { - if (typeof data === "string") { - return data; - } - if (Buffer.isBuffer(data)) { - return data.toString("utf-8"); - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf-8"); - } - if (Array.isArray(data)) { - return Buffer.concat(data).toString("utf-8"); - } - return null; - } } diff --git a/src/connection/WebSocketConnection.ts b/src/connection/WebSocketConnection.ts index cd190e8..61d0647 100644 --- a/src/connection/WebSocketConnection.ts +++ b/src/connection/WebSocketConnection.ts @@ -1,5 +1,6 @@ -import { EventEmitter } from "events"; -import WebSocket from "ws"; +import { Emitter } from "../platform/Emitter"; +import { getPlatformAdapter, WS_READY_STATE } from "../platform/index"; +import type { PlatformAdapter, UniversalWebSocket } from "../platform/types"; import { ConnectionOptions, ConnectionInfo, ConnectionState, ReconnectOptions } from "./types"; import { Logger } from "../logger/Logger"; @@ -12,19 +13,20 @@ import { Logger } from "../logger/Logger"; * - `"open"` — connection established * - `"close"` — connection closed (emits `{ code: number, reason: string }`) * - `"error"` — connection error (emits the `Error` object) - * - `"message"` — message received (emits the raw `WebSocket.Data`) + * - `"message"` — message received (emits the raw message data) * - `"reconnecting"` — about to attempt reconnection (emits `{ attempt, maxAttempts, delay, service }`) * - `"serviceSwitched"` — failed over to a different service URL (emits `{ from, to, cycle }`) */ -export class WebSocketConnection extends EventEmitter { +export class WebSocketConnection extends Emitter { private services: string[]; private queryParams: Record; private reconnectConfig: Required; private pingInterval: number; - private ws: WebSocket | null = null; - private pingTimer: NodeJS.Timeout | null = null; - private reconnectTimer: NodeJS.Timeout | null = null; + private adapter: PlatformAdapter; + private ws: UniversalWebSocket | null = null; + private pingTimer: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; private serviceIndex = 0; private reconnectAttempts = 0; @@ -38,6 +40,7 @@ export class WebSocketConnection extends EventEmitter { constructor(options: ConnectionOptions, logger: Logger) { super(); + this.adapter = getPlatformAdapter(); this.services = Array.isArray(options.service) ? options.service : [options.service]; this.queryParams = { ...options.queryParams }; this.pingInterval = options.pingInterval ?? 10000; @@ -86,8 +89,8 @@ export class WebSocketConnection extends EventEmitter { * Sends raw data through the WebSocket. * @returns `true` if the data was sent, `false` if the connection is not open. */ - send(data: string | Buffer): boolean { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { + send(data: string | ArrayBuffer | Uint8Array): boolean { + if (this.ws && this.ws.readyState === WS_READY_STATE.OPEN) { try { this.ws.send(data); return true; @@ -138,11 +141,11 @@ export class WebSocketConnection extends EventEmitter { private getState(): ConnectionState { if (!this.ws) return "closed"; switch (this.ws.readyState) { - case WebSocket.CONNECTING: + case WS_READY_STATE.CONNECTING: return "connecting"; - case WebSocket.OPEN: + case WS_READY_STATE.OPEN: return "connected"; - case WebSocket.CLOSING: + case WS_READY_STATE.CLOSING: return "closing"; default: return "closed"; @@ -175,40 +178,53 @@ export class WebSocketConnection extends EventEmitter { try { this.logger.info("Connecting to WebSocket", { service: serviceUrl }); - this.ws = new WebSocket(serviceUrl); + const ws = this.adapter.createWebSocket(serviceUrl); + this.ws = ws; - this.ws.on("open", () => { + ws.onopen = () => { this.isConnecting = false; this.reconnectAttempts = 0; this.serviceCycles = 0; this.startHeartbeat(); this.logger.info("WebSocket connected", { service: serviceUrl }); this.emit("open"); - }); + }; - this.ws.on("message", (data: WebSocket.Data) => { + ws.onmessage = (event: unknown) => { this.messageCount++; this.lastMessageTime = Date.now(); + // Node ws passes the data directly; browser wraps in MessageEvent + const data = event && typeof event === "object" && "data" in event + ? (event as { data: unknown }).data + : event; this.emit("message", data); - }); + }; - this.ws.on("error", (error: Error) => { + ws.onerror = (event: unknown) => { + const error = event instanceof Error + ? event + : new Error("WebSocket error"); this.logger.error("WebSocket error", error); this.isConnecting = false; this.emit("error", error); - }); - - this.ws.on("close", (code: number, reason: Buffer) => { - const reasonStr = reason.toString(); - this.logger.info("WebSocket disconnected", { code, reason: reasonStr }); + }; + + ws.onclose = (event: unknown) => { + const code = event && typeof event === "object" && "code" in event + ? (event as { code: number }).code + : 1006; + const reason = event && typeof event === "object" && "reason" in event + ? String((event as { reason: unknown }).reason) + : ""; + this.logger.info("WebSocket disconnected", { code, reason }); this.isConnecting = false; this.stopHeartbeat(); - this.emit("close", { code, reason: reasonStr }); + this.emit("close", { code, reason }); if (this.shouldReconnect) { this.scheduleReconnect(); } - }); + }; } catch (error) { this.logger.error("Error creating WebSocket", error); this.isConnecting = false; @@ -286,8 +302,11 @@ export class WebSocketConnection extends EventEmitter { private cleanup(): void { if (this.ws) { - this.ws.removeAllListeners(); - if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.adapter.removeAllListeners(this.ws); + if ( + this.ws.readyState === WS_READY_STATE.OPEN || + this.ws.readyState === WS_READY_STATE.CONNECTING + ) { try { this.ws.close(); } catch { @@ -300,11 +319,12 @@ export class WebSocketConnection extends EventEmitter { } private startHeartbeat(): void { + if (!this.adapter.supportsPing) return; this.stopHeartbeat(); this.pingTimer = setInterval(() => { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { + if (this.ws && this.ws.readyState === WS_READY_STATE.OPEN) { try { - this.ws.ping(); + this.adapter.ping(this.ws); } catch (error) { this.logger.error("Error sending ping", error); } diff --git a/src/index.ts b/src/index.ts index a5f8a4e..3a16914 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,3 +17,8 @@ export type { HandlerRegistration, HandlerError, } from "./router/types"; + +export { Emitter } from "./platform/Emitter"; +export { getPlatformAdapter } from "./platform/index"; +export type { PlatformAdapter, UniversalWebSocket } from "./platform/types"; +export { WS_READY_STATE } from "./platform/types"; diff --git a/src/platform/Emitter.ts b/src/platform/Emitter.ts new file mode 100644 index 0000000..bec3991 --- /dev/null +++ b/src/platform/Emitter.ts @@ -0,0 +1,43 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Listener = (...args: any[]) => void; + +/** + * Minimal cross-platform event emitter. + * Replaces Node's `EventEmitter` so the library works in browsers. + */ +export class Emitter { + private listeners = new Map>(); + + on(event: string, fn: Listener): this { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(fn); + return this; + } + + off(event: string, fn: Listener): this { + this.listeners.get(event)?.delete(fn); + return this; + } + + emit(event: string, ...args: unknown[]): boolean { + const set = this.listeners.get(event); + if (!set || set.size === 0) return false; + for (const fn of set) { + fn(...args); + } + return true; + } + + removeAllListeners(event?: string): this { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + return this; + } +} diff --git a/src/platform/browser.ts b/src/platform/browser.ts new file mode 100644 index 0000000..e540bd6 --- /dev/null +++ b/src/platform/browser.ts @@ -0,0 +1,35 @@ +import type { PlatformAdapter, UniversalWebSocket } from "./types"; + +const decoder = new TextDecoder(); + +/** + * Browser platform adapter. + * Uses the native `WebSocket` and `TextDecoder` for binary conversion. + */ +export function createBrowserAdapter(): PlatformAdapter { + return { + createWebSocket(url: string): UniversalWebSocket { + return new WebSocket(url); + }, + + dataToString(data: unknown): string | null { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return decoder.decode(data); + if (data instanceof Uint8Array) return decoder.decode(data); + return null; + }, + + supportsPing: false, + + ping(): void { + // Browser engines handle WebSocket keepalive at the protocol level. + }, + + removeAllListeners(ws: UniversalWebSocket): void { + ws.onopen = null; + ws.onclose = null; + ws.onerror = null; + ws.onmessage = null; + }, + }; +} diff --git a/src/platform/index.ts b/src/platform/index.ts new file mode 100644 index 0000000..88cc07f --- /dev/null +++ b/src/platform/index.ts @@ -0,0 +1,27 @@ +import type { PlatformAdapter } from "./types"; +import { createNodeAdapter } from "./node"; +import { createBrowserAdapter } from "./browser"; + +export type { PlatformAdapter, UniversalWebSocket } from "./types"; +export { WS_READY_STATE } from "./types"; +export { Emitter } from "./Emitter"; + +let cached: PlatformAdapter | null = null; + +function isNode(): boolean { + return ( + typeof process !== "undefined" && + typeof process.versions !== "undefined" && + typeof process.versions.node !== "undefined" + ); +} + +/** + * Returns a platform adapter for the current runtime. + * The result is cached — subsequent calls return the same instance. + */ +export function getPlatformAdapter(): PlatformAdapter { + if (cached) return cached; + cached = isNode() ? createNodeAdapter() : createBrowserAdapter(); + return cached; +} diff --git a/src/platform/node.ts b/src/platform/node.ts new file mode 100644 index 0000000..552a72c --- /dev/null +++ b/src/platform/node.ts @@ -0,0 +1,43 @@ +import type { PlatformAdapter, UniversalWebSocket } from "./types"; + +interface WsWebSocket extends UniversalWebSocket { + ping(): void; + removeAllListeners(): void; +} + +interface WsConstructor { + new (url: string): WsWebSocket; +} + +/** + * Node.js platform adapter. + * Uses the `ws` package for WebSocket and `Buffer` for binary conversion. + */ +export function createNodeAdapter(): PlatformAdapter { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const WS = require("ws") as WsConstructor; + + return { + createWebSocket(url: string): UniversalWebSocket { + return new WS(url); + }, + + dataToString(data: unknown): string | null { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf-8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8"); + return null; + }, + + supportsPing: true, + + ping(ws: UniversalWebSocket): void { + (ws as WsWebSocket).ping(); + }, + + removeAllListeners(ws: UniversalWebSocket): void { + (ws as WsWebSocket).removeAllListeners(); + }, + }; +} diff --git a/src/platform/types.ts b/src/platform/types.ts new file mode 100644 index 0000000..f258169 --- /dev/null +++ b/src/platform/types.ts @@ -0,0 +1,46 @@ +/** + * Minimal WebSocket interface that both Node `ws` and the browser + * native `WebSocket` satisfy. + */ +export interface UniversalWebSocket { + readonly readyState: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onopen: ((event: any) => void) | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onclose: ((event: any) => void) | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onerror: ((event: any) => void) | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onmessage: ((event: any) => void) | null; + close(code?: number, reason?: string): void; + send(data: string | ArrayBuffer | Uint8Array): void; +} + +/** Ready-state constants shared by both Node ws and browser WebSocket. */ +export const WS_READY_STATE = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +} as const; + +/** + * Abstracts the three platform-specific concerns: + * WebSocket construction, binary-to-string conversion, and ping support. + */ +export interface PlatformAdapter { + /** Creates a WebSocket connection to the given URL. */ + createWebSocket(url: string): UniversalWebSocket; + + /** Converts incoming message data to a UTF-8 string. */ + dataToString(data: unknown): string | null; + + /** Whether the platform supports sending ping frames. */ + readonly supportsPing: boolean; + + /** Sends a ping frame. No-op when `supportsPing` is false. */ + ping(ws: UniversalWebSocket): void; + + /** Removes all event handlers from a WebSocket instance. */ + removeAllListeners(ws: UniversalWebSocket): void; +} diff --git a/src/router/WebSocketRouter.ts b/src/router/WebSocketRouter.ts index f9df6b8..1bc91dd 100644 --- a/src/router/WebSocketRouter.ts +++ b/src/router/WebSocketRouter.ts @@ -1,5 +1,5 @@ -import { EventEmitter } from "events"; import { z } from "zod"; +import { Emitter } from "../platform/Emitter"; import { ConnectionInfo } from "../connection/types"; import { HandlerRegistration, HandlerError, MessageHandler } from "./types"; import { Logger } from "../logger/Logger"; @@ -15,7 +15,7 @@ import { Logger } from "../logger/Logger"; * Events emitted: * - `"error"` — a handler threw or JSON parsing failed (emits {@link HandlerError}) */ -export class WebSocketRouter extends EventEmitter { +export class WebSocketRouter extends Emitter { private handlers: HandlerRegistration[] = []; private logger: Logger; @@ -24,6 +24,7 @@ export class WebSocketRouter extends EventEmitter { this.logger = logger; } + /** * Registers a handler that will be invoked for every message matching the given schema. * diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..e31ab4a --- /dev/null +++ b/tests/client.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; +import type { PlatformAdapter, UniversalWebSocket } from "../src/platform/types"; +import { WS_READY_STATE } from "../src/platform/types"; + +interface MockWs extends UniversalWebSocket { + url: string; +} + +const mockWsInstances: MockWs[] = []; + +function createMockWs(url: string): MockWs { + const ws: MockWs = { + readyState: WS_READY_STATE.CONNECTING, + onopen: null, + onclose: null, + onerror: null, + onmessage: null, + close: vi.fn(), + send: vi.fn(), + url, + }; + mockWsInstances.push(ws); + return ws; +} + +const mockAdapter: PlatformAdapter = { + createWebSocket: (url: string) => createMockWs(url), + dataToString: (data: unknown) => { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (data instanceof Uint8Array) return new TextDecoder().decode(data); + if (Buffer.isBuffer(data)) return data.toString("utf-8"); + return null; + }, + supportsPing: false, + ping: vi.fn(), + removeAllListeners: (ws: UniversalWebSocket) => { + ws.onopen = null; + ws.onclose = null; + ws.onerror = null; + ws.onmessage = null; + }, +}; + +vi.mock("../src/platform/index", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getPlatformAdapter: () => mockAdapter, + }; +}); + +import { WebSocketClient } from "../src/WebSocketClient"; + +describe("WebSocketClient", () => { + beforeEach(() => { + mockWsInstances.length = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createClient( + options?: Partial[0]> + ): WebSocketClient { + return new WebSocketClient({ + service: "wss://test.example.com", + logger: { enabled: false }, + ...options, + }); + } + + function lastWs(): MockWs { + return mockWsInstances[mockWsInstances.length - 1]; + } + + it("connects and emits open event", () => { + const client = createClient(); + const onOpen = vi.fn(); + client.on("open", onOpen); + + client.connect(); + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + expect(onOpen).toHaveBeenCalled(); + }); + + it("emits close event with code and reason", () => { + const client = createClient(); + const onClose = vi.fn(); + client.on("close", onClose); + + client.connect(); + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + ws.readyState = WS_READY_STATE.CLOSED; + ws.onclose!({ code: 1000, reason: "normal" }); + + expect(onClose).toHaveBeenCalledWith({ code: 1000, reason: "normal" }); + }); + + it("emits error event on connection error", () => { + const client = createClient(); + const onError = vi.fn(); + client.on("error", onError); + + client.connect(); + const ws = lastWs(); + ws.onerror!(new Error("connection failed")); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it("routes messages through handlers", async () => { + const client = createClient(); + const schema = z.object({ type: z.literal("trade"), price: z.number() }); + const handler = vi.fn(); + + client.handle(schema, handler); + client.connect(); + + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + // Simulate incoming message — adapter extracts data from MessageEvent-like object + ws.onmessage!({ data: JSON.stringify({ type: "trade", price: 42.5 }) }); + + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + data: { type: "trade", price: 42.5 }, + }) + ); + }); + }); + + it("send() serializes objects as JSON", () => { + const client = createClient(); + client.connect(); + + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + const result = client.send({ action: "subscribe", channel: "trades" }); + + expect(result).toBe(true); + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ action: "subscribe", channel: "trades" }) + ); + }); + + it("send() passes strings through unchanged", () => { + const client = createClient(); + client.connect(); + + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + client.send("raw string"); + expect(ws.send).toHaveBeenCalledWith("raw string"); + }); + + it("send() returns false when not connected", () => { + const client = createClient(); + const result = client.send("data"); + expect(result).toBe(false); + }); + + it("close() stops the connection", () => { + const client = createClient(); + client.connect(); + + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + client.close(); + expect(ws.close).toHaveBeenCalled(); + }); + + it("getConnectionInfo() returns current state", () => { + const client = createClient(); + const info = client.getConnectionInfo(); + expect(info.state).toBe("closed"); + expect(info.currentService).toBe("wss://test.example.com"); + }); + + it("handle() returns this for chaining", () => { + const client = createClient(); + const schema = z.object({ type: z.string() }); + const result = client.handle(schema, () => {}); + expect(result).toBe(client); + }); + + it("emits router errors as error events", async () => { + const client = createClient(); + const onError = vi.fn(); + client.on("error", onError); + + client.connect(); + const ws = lastWs(); + ws.readyState = WS_READY_STATE.OPEN; + ws.onopen!({}); + + // Send invalid JSON to trigger router error + ws.onmessage!({ data: "not json!" }); + + await vi.waitFor(() => { + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + rawData: "not json!", + }) + ); + }); + }); +}); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts new file mode 100644 index 0000000..963dfa2 --- /dev/null +++ b/tests/emitter.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { Emitter } from "../src/platform/Emitter"; + +describe("Emitter", () => { + it("calls listeners when event is emitted", () => { + const emitter = new Emitter(); + const fn = vi.fn(); + emitter.on("test", fn); + emitter.emit("test", "a", 2); + expect(fn).toHaveBeenCalledWith("a", 2); + }); + + it("returns false when no listeners exist", () => { + const emitter = new Emitter(); + expect(emitter.emit("nope")).toBe(false); + }); + + it("returns true when listeners exist", () => { + const emitter = new Emitter(); + emitter.on("x", () => {}); + expect(emitter.emit("x")).toBe(true); + }); + + it("supports multiple listeners on the same event", () => { + const emitter = new Emitter(); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + emitter.on("e", fn1); + emitter.on("e", fn2); + emitter.emit("e", "data"); + expect(fn1).toHaveBeenCalledWith("data"); + expect(fn2).toHaveBeenCalledWith("data"); + }); + + it("removes a specific listener with off()", () => { + const emitter = new Emitter(); + const fn = vi.fn(); + emitter.on("e", fn); + emitter.off("e", fn); + emitter.emit("e"); + expect(fn).not.toHaveBeenCalled(); + }); + + it("removeAllListeners() with event name clears only that event", () => { + const emitter = new Emitter(); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + emitter.on("a", fn1); + emitter.on("b", fn2); + emitter.removeAllListeners("a"); + emitter.emit("a"); + emitter.emit("b"); + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).toHaveBeenCalled(); + }); + + it("removeAllListeners() without args clears all events", () => { + const emitter = new Emitter(); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + emitter.on("a", fn1); + emitter.on("b", fn2); + emitter.removeAllListeners(); + emitter.emit("a"); + emitter.emit("b"); + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).not.toHaveBeenCalled(); + }); + + it("on() returns this for chaining", () => { + const emitter = new Emitter(); + const result = emitter.on("e", () => {}); + expect(result).toBe(emitter); + }); + + it("does not add duplicate references of the same function", () => { + const emitter = new Emitter(); + const fn = vi.fn(); + emitter.on("e", fn); + emitter.on("e", fn); + emitter.emit("e"); + // Set prevents duplicates + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..ce2bf30 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Logger, LogLevel } from "../src/logger/Logger"; + +describe("Logger", () => { + const originalConsole = { ...console }; + + beforeEach(() => { + console.log = vi.fn(); + console.debug = vi.fn(); + console.warn = vi.fn(); + console.error = vi.fn(); + }); + + afterEach(() => { + console.log = originalConsole.log; + console.debug = originalConsole.debug; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + }); + + it("logs info by default", () => { + const logger = new Logger(); + logger.info("test message"); + expect(console.log).toHaveBeenCalledTimes(1); + }); + + it("suppresses debug when level is INFO", () => { + const logger = new Logger({ level: LogLevel.INFO }); + logger.debug("hidden"); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it("logs debug when level is DEBUG", () => { + const logger = new Logger({ level: LogLevel.DEBUG }); + logger.debug("visible"); + expect(console.debug).toHaveBeenCalledTimes(1); + }); + + it("logs nothing when disabled", () => { + const logger = new Logger({ enabled: false }); + logger.info("nope"); + logger.error("nope"); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("uses console.error for ERROR level", () => { + const logger = new Logger({ level: LogLevel.DEBUG }); + logger.error("fail"); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it("uses console.warn for WARN level", () => { + const logger = new Logger({ level: LogLevel.DEBUG }); + logger.warn("warning"); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it("delegates to custom logger when provided", () => { + const custom = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const logger = new Logger({ custom }); + logger.info("test", { key: "value" }); + expect(custom.info).toHaveBeenCalledWith("test", { key: "value" }); + expect(console.log).not.toHaveBeenCalled(); + }); + + it("includes context when provided", () => { + const logger = new Logger({ level: LogLevel.DEBUG }); + logger.debug("msg", { foo: 1 }); + expect(console.debug).toHaveBeenCalledWith( + expect.stringContaining("[WAH DEBUG]"), + "msg", + { foo: 1 } + ); + }); +}); diff --git a/tests/platform.test.ts b/tests/platform.test.ts new file mode 100644 index 0000000..55e354f --- /dev/null +++ b/tests/platform.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createNodeAdapter } from "../src/platform/node"; +import { createBrowserAdapter } from "../src/platform/browser"; + +describe("Node adapter", () => { + it("has supportsPing = true", () => { + const adapter = createNodeAdapter(); + expect(adapter.supportsPing).toBe(true); + }); + + it("dataToString handles string input", () => { + const adapter = createNodeAdapter(); + expect(adapter.dataToString("hello")).toBe("hello"); + }); + + it("dataToString handles Buffer input", () => { + const adapter = createNodeAdapter(); + const buf = Buffer.from("test", "utf-8"); + expect(adapter.dataToString(buf)).toBe("test"); + }); + + it("dataToString handles ArrayBuffer input", () => { + const adapter = createNodeAdapter(); + const ab = new TextEncoder().encode("ab").buffer; + expect(adapter.dataToString(ab)).toBe("ab"); + }); + + it("dataToString handles Buffer[] input", () => { + const adapter = createNodeAdapter(); + const bufs = [Buffer.from("a"), Buffer.from("b")]; + expect(adapter.dataToString(bufs)).toBe("ab"); + }); + + it("dataToString returns null for unsupported types", () => { + const adapter = createNodeAdapter(); + expect(adapter.dataToString(123)).toBeNull(); + }); + + it("ping calls ws.ping()", () => { + const adapter = createNodeAdapter(); + const mockWs = { ping: vi.fn() } as unknown as Parameters[0]; + adapter.ping(mockWs); + expect((mockWs as unknown as { ping: ReturnType }).ping).toHaveBeenCalled(); + }); + + it("removeAllListeners calls ws.removeAllListeners()", () => { + const adapter = createNodeAdapter(); + const mockWs = { removeAllListeners: vi.fn() } as unknown as Parameters< + typeof adapter.removeAllListeners + >[0]; + adapter.removeAllListeners(mockWs); + expect( + (mockWs as unknown as { removeAllListeners: ReturnType }).removeAllListeners + ).toHaveBeenCalled(); + }); +}); + +describe("Browser adapter", () => { + it("has supportsPing = false", () => { + const adapter = createBrowserAdapter(); + expect(adapter.supportsPing).toBe(false); + }); + + it("dataToString handles string input", () => { + const adapter = createBrowserAdapter(); + expect(adapter.dataToString("hello")).toBe("hello"); + }); + + it("dataToString handles ArrayBuffer input", () => { + const adapter = createBrowserAdapter(); + const ab = new TextEncoder().encode("test").buffer; + expect(adapter.dataToString(ab)).toBe("test"); + }); + + it("dataToString handles Uint8Array input", () => { + const adapter = createBrowserAdapter(); + const u8 = new TextEncoder().encode("bytes"); + expect(adapter.dataToString(u8)).toBe("bytes"); + }); + + it("dataToString returns null for unsupported types", () => { + const adapter = createBrowserAdapter(); + expect(adapter.dataToString(42)).toBeNull(); + }); + + it("ping is a no-op", () => { + const adapter = createBrowserAdapter(); + // Should not throw + adapter.ping({} as Parameters[0]); + }); + + it("removeAllListeners nulls out property handlers", () => { + const adapter = createBrowserAdapter(); + const mockWs = { + onopen: () => {}, + onclose: () => {}, + onerror: () => {}, + onmessage: () => {}, + } as unknown as Parameters[0]; + + adapter.removeAllListeners(mockWs); + expect(mockWs.onopen).toBeNull(); + expect(mockWs.onclose).toBeNull(); + expect(mockWs.onerror).toBeNull(); + expect(mockWs.onmessage).toBeNull(); + }); + + describe("createWebSocket", () => { + const originalWebSocket = globalThis.WebSocket; + + beforeEach(() => { + class MockWebSocket { + readyState = 0; + onopen: ((event: unknown) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onmessage: ((event: unknown) => void) | null = null; + close = vi.fn(); + send = vi.fn(); + constructor(public url: string) {} + } + (globalThis as unknown as Record).WebSocket = MockWebSocket; + }); + + afterEach(() => { + (globalThis as unknown as Record).WebSocket = originalWebSocket; + }); + + it("creates a WebSocket via globalThis.WebSocket", () => { + const adapter = createBrowserAdapter(); + const ws = adapter.createWebSocket("wss://example.com"); + expect(ws).toBeDefined(); + expect(ws.readyState).toBe(0); + }); + }); +}); + +describe("getPlatformAdapter", () => { + it("returns a Node adapter in Node.js", async () => { + // Reset the cache by re-importing + vi.resetModules(); + const { getPlatformAdapter } = await import("../src/platform/index"); + const adapter = getPlatformAdapter(); + expect(adapter.supportsPing).toBe(true); + }); + + it("caches the adapter", async () => { + vi.resetModules(); + const { getPlatformAdapter } = await import("../src/platform/index"); + const a = getPlatformAdapter(); + const b = getPlatformAdapter(); + expect(a).toBe(b); + }); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts new file mode 100644 index 0000000..dc78970 --- /dev/null +++ b/tests/router.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { WebSocketRouter } from "../src/router/WebSocketRouter"; +import { Logger } from "../src/logger/Logger"; +import type { ConnectionInfo } from "../src/connection/types"; + +function makeInfo(): ConnectionInfo { + return { + state: "connected", + currentService: "wss://test.example.com", + serviceIndex: 0, + allServices: ["wss://test.example.com"], + reconnectAttempts: 0, + serviceCycles: 0, + messageCount: 0, + lastMessageTime: 0, + }; +} + +describe("WebSocketRouter", () => { + const logger = new Logger({ enabled: false }); + + it("dispatches to a matching handler", async () => { + const router = new WebSocketRouter(logger); + const schema = z.object({ type: z.literal("ping") }); + const handler = vi.fn(); + router.register(schema, handler); + + const sendFn = vi.fn(() => true); + await router.route(JSON.stringify({ type: "ping" }), sendFn, makeInfo()); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + data: { type: "ping" }, + send: sendFn, + }) + ); + }); + + it("dispatches to multiple matching handlers", async () => { + const router = new WebSocketRouter(logger); + const schema1 = z.object({ type: z.string() }); + const schema2 = z.object({ type: z.literal("ping") }); + const handler1 = vi.fn(); + const handler2 = vi.fn(); + router.register(schema1, handler1); + router.register(schema2, handler2); + + await router.route(JSON.stringify({ type: "ping" }), vi.fn(() => true), makeInfo()); + + expect(handler1).toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + }); + + it("does not dispatch when no schema matches", async () => { + const router = new WebSocketRouter(logger); + const schema = z.object({ type: z.literal("ping") }); + const handler = vi.fn(); + router.register(schema, handler); + + await router.route(JSON.stringify({ type: "pong" }), vi.fn(() => true), makeInfo()); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("emits error on invalid JSON", async () => { + const router = new WebSocketRouter(logger); + const errorHandler = vi.fn(); + router.on("error", errorHandler); + + await router.route("not json!", vi.fn(() => true), makeInfo()); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + rawData: "not json!", + }) + ); + }); + + it("emits error when a handler throws", async () => { + const router = new WebSocketRouter(logger); + const schema = z.object({ type: z.literal("boom") }); + const handlerError = new Error("handler failed"); + router.register(schema, () => { + throw handlerError; + }); + + const errorHandler = vi.fn(); + router.on("error", errorHandler); + + await router.route(JSON.stringify({ type: "boom" }), vi.fn(() => true), makeInfo()); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: handlerError, + rawData: JSON.stringify({ type: "boom" }), + }) + ); + }); + + it("provides rawData and connection info in handler context", async () => { + const router = new WebSocketRouter(logger); + const schema = z.object({ v: z.number() }); + const handler = vi.fn(); + router.register(schema, handler); + + const info = makeInfo(); + const raw = JSON.stringify({ v: 42 }); + await router.route(raw, vi.fn(() => true), info); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + data: { v: 42 }, + rawData: raw, + connection: info, + }) + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index b05c289..b08d32e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "outDir": "dist", "module": "CommonJS", "target": "ES2020", diff --git a/tsup.config.ts b/tsup.config.ts index 43c86c3..be4249f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,4 +9,6 @@ export default defineConfig({ clean: true, target: "es2020", outDir: "dist", + external: ["ws"], + shims: true, }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3f824fb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +});