From 0be39724632054963c26921edbea9b123a92ea79 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 24 Jun 2025 10:46:43 +0200 Subject: [PATCH 01/12] initial testable signal client api --- package.json | 4 +- pnpm-lock.yaml | 805 +++++++++++++++++--------------------- src/api/SignalAPI.test.ts | 141 +++++++ src/api/SignalAPI.ts | 91 +++++ 4 files changed, 594 insertions(+), 447 deletions(-) create mode 100644 src/api/SignalAPI.test.ts create mode 100644 src/api/SignalAPI.ts diff --git a/package.json b/package.json index 36822039fb..f4aa8ed228 100644 --- a/package.json +++ b/package.json @@ -103,8 +103,8 @@ "typedoc": "0.28.5", "typedoc-plugin-no-inherit": "1.6.1", "typescript": "5.8.3", - "vite": "5.4.19", - "vitest": "^1.6.0" + "vite": "6.3.5", + "vitest": "^3.2.4" }, "packageManager": "pnpm@9.15.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4bd8be9f1..eaa508e256 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,11 +145,11 @@ importers: specifier: 5.8.3 version: 5.8.3 vite: - specifier: 5.4.19 - version: 5.4.19(@types/node@22.7.4)(terser@5.39.2) + specifier: 6.3.5 + version: 6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1) vitest: - specifier: ^1.6.0 - version: 1.6.1(@types/node@22.7.4)(happy-dom@17.6.3)(jsdom@26.1.0)(terser@5.39.2) + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.7.4)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.7.1) packages: @@ -816,141 +816,153 @@ packages: resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -988,10 +1000,6 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -1223,9 +1231,6 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@size-limit/file@11.2.0': resolution: {integrity: sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1254,6 +1259,12 @@ packages: svelte: optional: true + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dom-mediacapture-record@1.0.22': resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} @@ -1362,20 +1373,34 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@vitest/expect@1.6.1': - resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@1.6.1': - resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@1.6.1': - resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/spy@1.6.1': - resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/utils@1.6.1': - resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1433,10 +1458,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - acorn@8.11.3: resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} engines: {node: '>=0.4.0'} @@ -1486,10 +1507,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1524,8 +1541,9 @@ packages: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} @@ -1598,9 +1616,9 @@ packages: caniuse-lite@1.0.30001699: resolution: {integrity: sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==} - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1609,8 +1627,9 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -1648,9 +1667,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confusing-browser-globals@1.0.11: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} @@ -1708,15 +1724,6 @@ packages: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1729,8 +1736,8 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} deep-is@0.1.4: @@ -1756,10 +1763,6 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1820,6 +1823,9 @@ packages: es-module-lexer@1.3.1: resolution: {integrity: sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.0.0: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} @@ -1835,9 +1841,9 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} hasBin: true escalade@3.1.2: @@ -1986,9 +1992,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -2100,17 +2106,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - get-symbol-description@1.0.2: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} @@ -2206,10 +2205,6 @@ packages: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2325,10 +2320,6 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -2450,10 +2441,6 @@ packages: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} - local-pkg@0.5.1: - resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} - engines: {node: '>=14'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2478,8 +2465,8 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2527,10 +2514,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2541,9 +2524,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mlly@1.7.4: - resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2554,8 +2534,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2588,10 +2568,6 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -2625,10 +2601,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -2652,10 +2624,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -2698,10 +2666,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2709,14 +2673,12 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2737,15 +2699,12 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} - postcss@8.4.44: - resolution: {integrity: sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2762,10 +2721,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -2780,9 +2735,6 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -2958,8 +2910,8 @@ packages: smob@1.4.1: resolution: {integrity: sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -2978,8 +2930,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.8.1: - resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} string.prototype.trim@1.2.9: resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} @@ -3000,16 +2952,12 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-literal@2.1.1: - resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} strip-outer@1.0.1: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} @@ -3070,16 +3018,27 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} tldts-core@6.1.86: @@ -3131,10 +3090,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -3180,17 +3135,14 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.0-dev.20250616: - resolution: {integrity: sha512-TdYn1diKBbeAZIOI2A23QYCVrMdwj4AhDPE9kdenLxqVFVfFB+CADDXUi+4f91RQie1QEw6RcmmL32FXu7jE+Q==} + typescript@5.9.0-dev.20250623: + resolution: {integrity: sha512-GAB5O3HdLieu0gYjeDknnqsbFJxJkKawGJRNrY+1E2Zuzh+fQX2d/VwPXmnY7mU1HQUjka/tCcSc5ZXbSFy1Lw==} engines: {node: '>=14.17'} hasBin: true uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -3236,27 +3188,32 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite-node@1.6.1: - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@5.4.19: - resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.3.5: + resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -3271,21 +3228,28 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@1.6.1: - resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.1 - '@vitest/ui': 1.6.1 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -3403,10 +3367,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.2.1: - resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} - engines: {node: '>=12.20'} - snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} @@ -4341,73 +4301,79 @@ snapshots: '@csstools/css-tokenizer@3.0.3': {} - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.25.5': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm64@0.25.5': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm@0.25.5': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/android-x64@0.25.5': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-arm64@0.25.5': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/darwin-x64@0.25.5': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-arm64@0.25.5': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/freebsd-x64@0.25.5': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm64@0.25.5': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-arm@0.25.5': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ia32@0.25.5': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-loong64@0.25.5': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-mips64el@0.25.5': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-ppc64@0.25.5': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-riscv64@0.25.5': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-s390x@0.25.5': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/linux-x64@0.25.5': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-arm64@0.25.5': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/netbsd-x64@0.25.5': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.5': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/openbsd-x64@0.25.5': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/sunos-x64@0.25.5': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': optional: true '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': @@ -4453,10 +4419,6 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -4664,8 +4626,6 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@sinclair/typebox@0.27.8': {} - '@size-limit/file@11.2.0(size-limit@11.2.0)': dependencies: size-limit: 11.2.0 @@ -4693,6 +4653,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + '@types/dom-mediacapture-record@1.0.22': {} '@types/eslint-scope@3.7.7': @@ -4821,34 +4787,47 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/expect@1.6.1': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - chai: 4.5.0 + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.0 + tinyrainbow: 2.0.0 - '@vitest/runner@1.6.1': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1))': dependencies: - '@vitest/utils': 1.6.1 - p-limit: 5.0.0 - pathe: 1.1.2 + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1) - '@vitest/snapshot@1.6.1': + '@vitest/pretty-format@3.2.4': dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 - pathe: 1.1.2 - pretty-format: 29.7.0 + pathe: 2.0.3 - '@vitest/spy@1.6.1': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 2.2.1 + tinyspy: 4.0.3 - '@vitest/utils@1.6.1': + '@vitest/utils@3.2.4': dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -4934,10 +4913,6 @@ snapshots: dependencies: acorn: 8.11.3 - acorn-walk@8.3.4: - dependencies: - acorn: 8.14.1 - acorn@8.11.3: {} acorn@8.14.1: {} @@ -4980,8 +4955,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -5038,7 +5011,7 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 - assertion-error@1.1.0: {} + assertion-error@2.0.1: {} async@3.2.5: {} @@ -5121,15 +5094,13 @@ snapshots: caniuse-lite@1.0.30001699: {} - chai@4.5.0: + chai@5.2.0: dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.4 + pathval: 2.0.0 chalk@4.1.2: dependencies: @@ -5138,9 +5109,7 @@ snapshots: chardet@0.7.0: {} - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 + check-error@2.1.1: {} chokidar@4.0.3: dependencies: @@ -5166,8 +5135,6 @@ snapshots: concat-map@0.0.1: {} - confbox@0.1.8: {} - confusing-browser-globals@1.0.11: {} convert-source-map@2.0.0: {} @@ -5226,19 +5193,13 @@ snapshots: dependencies: ms: 2.1.2 - debug@4.4.0: - dependencies: - ms: 2.1.3 - debug@4.4.1: dependencies: ms: 2.1.3 decimal.js@10.5.0: {} - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -5269,8 +5230,6 @@ snapshots: detect-indent@6.1.0: {} - diff-sequences@29.6.3: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -5289,7 +5248,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 5.9.0-dev.20250616 + typescript: 5.9.0-dev.20250623 electron-to-chromium@1.5.4: {} @@ -5368,6 +5327,8 @@ snapshots: es-module-lexer@1.3.1: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.0.0: dependencies: es-errors: 1.3.0 @@ -5388,31 +5349,33 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild@0.21.5: + esbuild@0.25.5: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 escalade@3.1.2: {} @@ -5596,17 +5559,7 @@ snapshots: events@3.3.0: {} - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 + expect-type@1.2.1: {} extendable-error@0.1.7: {} @@ -5726,8 +5679,6 @@ snapshots: gensync@1.0.0-beta.2: {} - get-func-name@2.0.2: {} - get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -5736,8 +5687,6 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 - get-stream@8.0.1: {} - get-symbol-description@1.0.2: dependencies: call-bind: 1.0.7 @@ -5845,8 +5794,6 @@ snapshots: human-id@4.1.1: {} - human-signals@5.0.0: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5946,8 +5893,6 @@ snapshots: dependencies: call-bind: 1.0.7 - is-stream@3.0.0: {} - is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 @@ -6071,11 +6016,6 @@ snapshots: loader-runner@4.3.0: {} - local-pkg@0.5.1: - dependencies: - mlly: 1.7.4 - pkg-types: 1.3.1 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -6094,9 +6034,7 @@ snapshots: loglevel@1.9.2: {} - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 + loupe@3.1.4: {} lru-cache@10.4.3: {} @@ -6144,8 +6082,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@4.0.0: {} - minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6156,20 +6092,13 @@ snapshots: minimist@1.2.8: {} - mlly@1.7.4: - dependencies: - acorn: 8.14.1 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.5.4 - mri@1.2.0: {} ms@2.1.2: {} ms@2.1.3: {} - nanoid@3.3.7: {} + nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -6189,10 +6118,6 @@ snapshots: node-releases@2.0.19: {} - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - nwsapi@2.2.20: {} object-inspect@1.13.1: {} @@ -6235,10 +6160,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -6264,10 +6185,6 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@5.0.0: - dependencies: - yocto-queue: 1.2.1 - p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -6300,17 +6217,13 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-type@4.0.0: {} - pathe@1.1.2: {} - pathe@2.0.3: {} - pathval@1.1.1: {} + pathval@2.0.0: {} picocolors@1.1.1: {} @@ -6324,19 +6237,13 @@ snapshots: dependencies: find-up: 4.1.0 - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.7.4 - pathe: 2.0.3 - possible-typed-array-names@1.0.0: {} - postcss@8.4.44: + postcss@8.5.6: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.11 picocolors: 1.1.1 - source-map-js: 1.2.0 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} @@ -6344,12 +6251,6 @@ snapshots: prettier@3.5.3: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -6360,8 +6261,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - react-is@18.3.1: {} - read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -6572,7 +6471,7 @@ snapshots: smob@1.4.1: {} - source-map-js@1.2.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: dependencies: @@ -6590,7 +6489,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.8.1: {} + std-env@3.9.0: {} string.prototype.trim@1.2.9: dependencies: @@ -6617,11 +6516,9 @@ snapshots: strip-bom@3.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@3.1.1: {} - strip-literal@2.1.1: + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 @@ -6672,14 +6569,23 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 - tinypool@0.8.4: {} + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.1.1: {} - tinyspy@2.2.1: {} + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} tldts-core@6.1.86: {} @@ -6728,8 +6634,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.1.0: {} - type-fest@0.20.2: {} typed-array-buffer@1.0.2: @@ -6785,12 +6689,10 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.0-dev.20250616: {} + typescript@5.9.0-dev.20250623: {} uc.micro@2.1.0: {} - ufo@1.5.4: {} - unbox-primitive@1.0.2: dependencies: call-bind: 1.0.7 @@ -6831,15 +6733,16 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@1.6.1(@types/node@22.7.4)(terser@5.39.2): + vite-node@3.2.4(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1): dependencies: cac: 6.7.14 debug: 4.4.1 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.19(@types/node@22.7.4)(terser@5.39.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -6848,52 +6751,66 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vite@5.4.19(@types/node@22.7.4)(terser@5.39.2): + vite@6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1): dependencies: - esbuild: 0.21.5 - postcss: 8.4.44 + esbuild: 0.25.5 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.6 rollup: 4.43.0 + tinyglobby: 0.2.13 optionalDependencies: '@types/node': 22.7.4 fsevents: 2.3.3 + jiti: 2.4.2 terser: 5.39.2 + yaml: 2.7.1 - vitest@1.6.1(@types/node@22.7.4)(happy-dom@17.6.3)(jsdom@26.1.0)(terser@5.39.2): - dependencies: - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.4 - chai: 4.5.0 - debug: 4.4.0 - execa: 8.0.1 - local-pkg: 0.5.1 + vitest@3.2.4(@types/node@22.7.4)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.7.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.0 + debug: 4.4.1 + expect-type: 1.2.1 magic-string: 0.30.17 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.8.1 - strip-literal: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.19(@types/node@22.7.4)(terser@5.39.2) - vite-node: 1.6.1(@types/node@22.7.4)(terser@5.39.2) + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1) + vite-node: 3.2.4(@types/node@22.7.4)(jiti@2.4.2)(terser@5.39.2)(yaml@2.7.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.7.4 happy-dom: 17.6.3 jsdom: 26.1.0 transitivePeerDependencies: + - jiti - less - lightningcss + - msw - sass - sass-embedded - stylus - sugarss - supports-color - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: @@ -7003,5 +6920,3 @@ snapshots: yaml@2.7.1: {} yocto-queue@0.1.0: {} - - yocto-queue@1.2.1: {} diff --git a/src/api/SignalAPI.test.ts b/src/api/SignalAPI.test.ts new file mode 100644 index 0000000000..d7f6e190c4 --- /dev/null +++ b/src/api/SignalAPI.test.ts @@ -0,0 +1,141 @@ +import { JoinResponse, ReconnectResponse } from '@livekit/protocol'; +import { describe, expect, it, vi } from 'vitest'; +import { sleep } from '../room/utils'; +import { SignalAPI } from './SignalAPI'; +import type { ITransport } from './SignalAPI'; + +// A helper to create a minimal dummy transport whose methods are jest/vi spies +function createDummyTransport(overrides: Partial = {}): ITransport { + // placeholders that will be overridden when `onMessage` / `onError` are registered + let messageHandler: ((data: Uint8Array) => void) | undefined; + let errorHandler: ((error: Error) => void) | undefined; + + const dummyTransport: ITransport = { + connect: vi.fn(async (...args: unknown[]) => { + void args; // silence unused parameter lint errors + return {} as unknown as JoinResponse; + }), + send: vi.fn(async () => {}), + close: vi.fn(async () => {}), + reconnect: vi.fn(async () => ({}) as unknown as ReconnectResponse), + onMessage: (cb) => { + messageHandler = cb; + }, + onError: (cb) => { + errorHandler = cb; + }, + ...overrides, + } as ITransport; + + // Expose ways to trigger the callbacks inside tests + // @ts-expect-error – we attach these for test-only usage + dummyTransport.__triggerMessage = (data: Uint8Array) => messageHandler?.(data); + // @ts-expect-error – we attach these for test-only usage + dummyTransport.__triggerError = (err: Error) => errorHandler?.(err); + + return dummyTransport; +} + +describe('SignalAPI', () => { + it('calls transport.connect when join is invoked', async () => { + const joinResponse = { joined: true } as unknown as JoinResponse; + + const transport = createDummyTransport({ + connect: vi.fn(async () => joinResponse), + }); + + const api = new SignalAPI(transport); + void api; + + const url = 'wss://example.com'; + const token = 'fake-token'; + + const result = await api.join(url, token); + + expect(transport.connect).toHaveBeenCalledWith(url, token); + expect(result).toBe(joinResponse); + }); + + it('forwards reconnect to transport.reconnect', async () => { + const reconnectResponse = { reconnected: true } as unknown as ReconnectResponse; + + const transport = createDummyTransport({ + reconnect: vi.fn(async () => reconnectResponse), + }); + + const api = new SignalAPI(transport); + void api; + + const result = await api.reconnect(); + + expect(transport.reconnect).toHaveBeenCalled(); + expect(result).toBe(reconnectResponse); + }); + + it('handles onMessage events from the transport', () => { + const transport = createDummyTransport(); + const api = new SignalAPI(transport); + void api; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // @ts-expect-error – trigger helper added in createDummyTransport + transport.__triggerMessage(new Uint8Array([1, 2, 3])); + + expect(consoleSpy).toHaveBeenCalledWith('onMessage', new Uint8Array([1, 2, 3])); + + consoleSpy.mockRestore(); + }); + + it('handles onError events from the transport', () => { + const transport = createDummyTransport(); + const api = new SignalAPI(transport); + void api; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const error = new Error('dummy'); + // @ts-expect-error – trigger helper added in createDummyTransport + transport.__triggerError(error); + + expect(consoleErrorSpy).toHaveBeenCalledWith('onError', error); + + consoleErrorSpy.mockRestore(); + }); + + it('ensures parallel join calls are executed sequentially', async () => { + const resolvers: Array<() => void> = []; + + const connect = vi.fn((url: string, token: string) => { + void url; + void token; + return new Promise((resolve) => { + resolvers.push(() => resolve({} as unknown as JoinResponse)); + }); + }); + + const transport = createDummyTransport({ connect }); + const api = new SignalAPI(transport); + void api; + + // Trigger two join calls without awaiting the first + const joinPromise1 = api.join('wss://example.com', 'token-1'); + const joinPromise2 = api.join('wss://example.com', 'token-2'); + + // Only the first connect should have been invoked at this point + await sleep(5); + expect(connect).toHaveBeenCalledTimes(1); + + // Resolve the first join + resolvers[0](); + await joinPromise1; + + // Now the second connect should have been called + await sleep(5); + expect(connect).toHaveBeenCalledTimes(2); + + // Resolve the second join + resolvers[1](); + await joinPromise2; + }); +}); diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts new file mode 100644 index 0000000000..cdcc7f1221 --- /dev/null +++ b/src/api/SignalAPI.ts @@ -0,0 +1,91 @@ +import { Mutex } from '@livekit/mutex'; +import { JoinResponse, ReconnectResponse } from '@livekit/protocol'; + +export interface ITransport { + connect(url: string, token: string): Promise; + send(data: Uint8Array): Promise; + close(): Promise; + reconnect(): Promise; + onMessage(callback: (data: Uint8Array) => void): void; + onError(callback: (error: Error) => void): void; +} + +export interface ITransportFactory { + create(url: string): Promise; +} + +export class SignalAPI { + constructor(private transport: ITransport) { + this.transport.onMessage(this.onMessage); + this.transport.onError(this.onError); + } + + @bound + private onMessage(data: Uint8Array) { + console.log('onMessage', data); + } + + @bound + private onError(error: Error) { + console.error('onError', error); + } + + @atomic + join(url: string, token: string): Promise { + return this.transport.connect(url, token); + } + + @loggedMethod + reconnect(): Promise { + return this.transport.reconnect(); + } + + @loggedMethod + close() {} +} + +function bound( + target: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext Return> +) { + const methodName = context.name; + if (context.private) { + throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`); + } + context.addInitializer(function () { + // @ts-ignore + this[methodName] = this[methodName].bind(this); + }); +} + +function loggedMethod( + target: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext Return> +) { + const methodName = String(context.name); + + function replacementMethod(this: This, ...args: Args): Return { + console.debug(`LOG: Entering method '${methodName}'.`) + const result = target.call(this, ...args); + console.debug(`LOG: Exiting method '${methodName}'.`) + return result; + } + + return replacementMethod; +} + +function atomic(originalMethod: any) { + const mutex = new Mutex(); + + async function replacementMethod(this: any, ...args: any[]) { + const unlock = await mutex.lock(); + try { + const result = await originalMethod.call(this, ...args); + return result; + } finally { + unlock(); + } + } + + return replacementMethod; +} \ No newline at end of file From bc2c3d97dc69aec1cfcb4ffbc83edf0f05490f3f Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 1 Jul 2025 11:45:18 +0200 Subject: [PATCH 02/12] wip --- src/api/SignalAPI.ts | 151 +++++++++++++++++++++++-------------- src/api/SignalTransport.ts | 122 ++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 56 deletions(-) create mode 100644 src/api/SignalTransport.ts diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index cdcc7f1221..1646e5b35f 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,78 +1,113 @@ import { Mutex } from '@livekit/mutex'; -import { JoinResponse, ReconnectResponse } from '@livekit/protocol'; - -export interface ITransport { - connect(url: string, token: string): Promise; - send(data: Uint8Array): Promise; - close(): Promise; - reconnect(): Promise; - onMessage(callback: (data: Uint8Array) => void): void; - onError(callback: (error: Error) => void): void; -} +import { JoinResponse, SessionDescription, SignalRequest, SignalResponse } from '@livekit/protocol'; +import type { ITransport } from './SignalTransport'; +import { Future } from '../room/utils'; -export interface ITransportFactory { - create(url: string): Promise; -} export class SignalAPI { - constructor(private transport: ITransport) { - this.transport.onMessage(this.onMessage); - this.transport.onError(this.onError); + + private writer?: WritableStreamDefaultWriter; + + private promiseMap = new Map>(); + + private offerId = 0; + + private transport: ITransport; + + constructor(transport: ITransport) { + this.transport = transport; } - @bound - private onMessage(data: Uint8Array) { - console.log('onMessage', data); + @atomic + async join(url: string, token: string): Promise { + const { readableStream, writableStream, joinResponse } = await this.transport.connect({ url, token }); + this.readLoop(readableStream); + this.writer = writableStream.getWriter(); + return joinResponse; } - @bound - private onError(error: Error) { - console.error('onError', error); + async readLoop(readableStream: ReadableStream) { + const reader = readableStream.getReader(); + while (true) { + try { + + + const { done, value } = await reader.read(); + if(!value) { + continue; + } + // @ts-ignore + const responseKey = getResponseKey(value.message.case, value.message.value!.id as number); + const future = this.promiseMap.get(responseKey); + if (future) { + future.resolve?.(value); + } + + if (done) break; + } catch(e) { + Array.from(this.promiseMap.values()).forEach(future => future.reject?.(e)); + this.promiseMap.clear(); + break; + } + } } @atomic - join(url: string, token: string): Promise { - return this.transport.connect(url, token); - } + async sendOfferAndAwaitAnswer(offer: RTCSessionDescriptionInit): Promise { + const offerId = this.offerId++; + if(!this.writer) { + throw new Error('Writable stream not initialized'); + } - @loggedMethod - reconnect(): Promise { - return this.transport.reconnect(); + const request = new SessionDescription({ + type: 'offer', + sdp: offer.sdp, + // id: offer.id, + }); + + await this.writer.write(new SignalRequest({ + message: { case: 'offer', value: request }, + })); + + const future = new Future(); + // we want an answer for this offer so we queue up a future for it + this.promiseMap.set(getResponseKey('answer', offerId), future); + const answerResponse = await future.promise; + + if(answerResponse.message.case === 'answer') { + return answerResponse.message.value; + } + + throw new Error('Answer not found'); } - @loggedMethod - close() {} -} + // @loggedMethod + async reconnect(): Promise { + //return this.transport.reconnect(); + } -function bound( - target: (this: This, ...args: Args) => Return, - context: ClassMethodDecoratorContext Return> -) { - const methodName = context.name; - if (context.private) { - throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`); + // @loggedMethod + close() { + this.transport.close(); } - context.addInitializer(function () { - // @ts-ignore - this[methodName] = this[methodName].bind(this); - }); } -function loggedMethod( - target: (this: This, ...args: Args) => Return, - context: ClassMethodDecoratorContext Return> -) { - const methodName = String(context.name); - - function replacementMethod(this: This, ...args: Args): Return { - console.debug(`LOG: Entering method '${methodName}'.`) - const result = target.call(this, ...args); - console.debug(`LOG: Exiting method '${methodName}'.`) - return result; - } - return replacementMethod; -} +// function loggedMethod( +// target: (this: This, ...args: Args) => Return, +// context: ClassMethodDecoratorContext Return> +// ) { +// const methodName = String(context.name); + +// function replacementMethod(this: This, ...args: Args): Return { +// console.debug(`LOG: Entering method '${methodName}'.`) +// const result = target.call(this, ...args); +// console.debug(`LOG: Exiting method '${methodName}'.`) +// return result; +// } + +// return replacementMethod; +// } function atomic(originalMethod: any) { const mutex = new Mutex(); @@ -88,4 +123,8 @@ function atomic(originalMethod: any) { } return replacementMethod; +} + +function getResponseKey(requestType: SignalResponse['message']['case'], requestId: number) { + return `${requestType}-${requestId}`; } \ No newline at end of file diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts new file mode 100644 index 0000000000..e2fb44aef1 --- /dev/null +++ b/src/api/SignalTransport.ts @@ -0,0 +1,122 @@ +import type { JoinResponse, ReconnectResponse } from '@livekit/protocol'; +import { SignalRequest, SignalResponse } from '@livekit/protocol'; + +export interface ITransportOptions { + url: string; + token: string; +} + +export interface ITransportConnection { + joinResponse: JoinResponse; + readableStream: ReadableStream; + writableStream: WritableStream; +} + +export interface ITransport { + connect(options: ITransportOptions): Promise; + reconnect(options: ITransportOptions): Promise; + close(): Promise; +} + +export interface ITransportFactory { + create(): Promise; +} + +export class HybridSignalTransport implements ITransport { + private readonly pc: RTCPeerConnection; + + private dc?: RTCDataChannel; + + abortController: AbortController; + + constructor(peerConnection: RTCPeerConnection) { + this.pc = peerConnection; + this.abortController = new AbortController(); + } + + async connect({ url, token }: ITransportOptions): Promise { + const dc = this.pc.createDataChannel('signal', { + ordered: true, + maxRetransmits: 0, + }); + this.dc = dc; + + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + + const joinRequest = await fetch(`${url}/join`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const joinResponse = await joinRequest.json(); + + const readableStream = new ReadableStream({ + start: (controller) => { + dc.addEventListener('message', (event: MessageEvent) => { + controller.enqueue(SignalResponse.fromBinary(new Uint8Array(event.data))); + }); + + dc.addEventListener('error', (event: Event) => { + controller.error(event); + }); + + dc.addEventListener('closed', () => { + controller.close(); + }); + }, + }); + + const writableStream = new WritableStream({ + async write(request: SignalRequest) { + if (dc.readyState === 'closing' || dc.readyState === 'closed') { + throw new Error('Signalling channel is closed'); + } + + if (dc.readyState !== 'open') { + await new Promise((resolve, reject) => { + dc.addEventListener('open', resolve); + dc.addEventListener('error', reject); + }); + } + // TODO chunking + dc.send(request.toBinary()); + }, + close: () => { + dc.close(); + }, + + abort: () => { + dc.close(); + }, + }); + + return { + joinResponse, + readableStream, + writableStream, + }; + } + + async reconnect(options: ITransportOptions): Promise { + const reconnectRequest = await fetch(`${options.url}/reconnect`, { + method: 'POST', + headers: { + Authorization: `Bearer ${options.token}`, + }, + }); + + const reconnectResponse = await reconnectRequest.json(); + // TODO is this enough? ideally we could just continue to use the existing streams from the connect method + this.pc.restartIce(); + + return reconnectResponse; + } + + async close(): Promise { + this.abortController.abort(); + this.dc?.close(); + } +} From 0abbc7f9d9cbde0846c184b60e7d7e9b7ae12bb1 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 17 Jul 2025 18:15:05 +0200 Subject: [PATCH 03/12] more wip --- src/api/SignalAPI.ts | 18 ++++++---- src/api/SignalTransport.ts | 74 ++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index 1646e5b35f..914126dbe5 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,7 +1,7 @@ import { Mutex } from '@livekit/mutex'; -import { JoinResponse, SessionDescription, SignalRequest, SignalResponse } from '@livekit/protocol'; +import { ConnectionSettings, ConnectRequest, ConnectResponse, SessionDescription, SignalRequest, SignalResponse } from '@livekit/protocol'; import type { ITransport } from './SignalTransport'; -import { Future } from '../room/utils'; +import { Future, getClientInfo } from '../room/utils'; export class SignalAPI { @@ -19,18 +19,24 @@ export class SignalAPI { } @atomic - async join(url: string, token: string): Promise { - const { readableStream, writableStream, joinResponse } = await this.transport.connect({ url, token }); + async join(url: string, token: string): Promise { + const connectRequest = new ConnectRequest({ + clientInfo: getClientInfo(), + connectionSettings: new ConnectionSettings({ + adaptiveStream: true, + autoSubscribe: true, + }) + }); + const { readableStream, writableStream, connectResponse } = await this.transport.connect({ url, token, connectRequest }); this.readLoop(readableStream); this.writer = writableStream.getWriter(); - return joinResponse; + return connectResponse; } async readLoop(readableStream: ReadableStream) { const reader = readableStream.getReader(); while (true) { try { - const { done, value } = await reader.read(); if(!value) { diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index e2fb44aef1..f550aacc9c 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -1,20 +1,30 @@ -import type { JoinResponse, ReconnectResponse } from '@livekit/protocol'; -import { SignalRequest, SignalResponse } from '@livekit/protocol'; +import { + ConnectRequest, + ConnectResponse, + Signalv2ClientEnvelope, + Signalv2ClientMessage, + Signalv2ServerMessage, +} from '@livekit/protocol'; export interface ITransportOptions { url: string; token: string; + connectRequest: ConnectRequest; } +type ValidServerMessage = Exclude< + NonNullable, + { case: 'fragment' } | { case: 'envelope' } | { case: undefined } +>; + export interface ITransportConnection { - joinResponse: JoinResponse; - readableStream: ReadableStream; - writableStream: WritableStream; + connectResponse: ConnectResponse; + readableStream: ReadableStream; + writableStream: WritableStream; } export interface ITransport { connect(options: ITransportOptions): Promise; - reconnect(options: ITransportOptions): Promise; close(): Promise; } @@ -34,29 +44,43 @@ export class HybridSignalTransport implements ITransport { this.abortController = new AbortController(); } - async connect({ url, token }: ITransportOptions): Promise { + async connect({ url, token, connectRequest }: ITransportOptions): Promise { const dc = this.pc.createDataChannel('signal', { ordered: true, maxRetransmits: 0, }); this.dc = dc; - const offer = await this.pc.createOffer(); - await this.pc.setLocalDescription(offer); - const joinRequest = await fetch(`${url}/join`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, }, + body: connectRequest.toBinary(), + signal: this.abortController.signal, }); - const joinResponse = await joinRequest.json(); + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); - const readableStream = new ReadableStream({ + const connectResponse = ConnectResponse.fromBinary( + new Uint8Array(await joinRequest.arrayBuffer()), + ); + + const readableStream = new ReadableStream({ start: (controller) => { dc.addEventListener('message', (event: MessageEvent) => { - controller.enqueue(SignalResponse.fromBinary(new Uint8Array(event.data))); + const serverMessage = Signalv2ServerMessage.fromBinary(new Uint8Array(event.data)); + switch (serverMessage.message.case) { + case 'envelope': + controller.enqueue(serverMessage.message.value); + break; + case 'connectResponse': + controller.enqueue(serverMessage.message.value); + break; + default: + throw new Error(`Unknown server message type: ${serverMessage.message.case}`); + } }); dc.addEventListener('error', (event: Event) => { @@ -70,7 +94,7 @@ export class HybridSignalTransport implements ITransport { }); const writableStream = new WritableStream({ - async write(request: SignalRequest) { + async write(request: Signalv2ClientMessage) { if (dc.readyState === 'closing' || dc.readyState === 'closed') { throw new Error('Signalling channel is closed'); } @@ -81,8 +105,11 @@ export class HybridSignalTransport implements ITransport { dc.addEventListener('error', reject); }); } + const envelope = new Signalv2ClientEnvelope({ + clientMessages: [request], + }); // TODO chunking - dc.send(request.toBinary()); + dc.send(envelope.toBinary()); }, close: () => { dc.close(); @@ -94,27 +121,12 @@ export class HybridSignalTransport implements ITransport { }); return { - joinResponse, + connectResponse, readableStream, writableStream, }; } - async reconnect(options: ITransportOptions): Promise { - const reconnectRequest = await fetch(`${options.url}/reconnect`, { - method: 'POST', - headers: { - Authorization: `Bearer ${options.token}`, - }, - }); - - const reconnectResponse = await reconnectRequest.json(); - // TODO is this enough? ideally we could just continue to use the existing streams from the connect method - this.pc.restartIce(); - - return reconnectResponse; - } - async close(): Promise { this.abortController.abort(); this.dc?.close(); From 5be7ddf1adaf49a2ce064deb11322e2aac5fdd2b Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 18 Jul 2025 09:30:31 +0200 Subject: [PATCH 04/12] new envelope handling --- pnpm-lock.yaml | 14 ++++------- pnpm-workspace.yaml | 2 ++ src/api/SignalTransport.ts | 49 +++++++++++++++++++++++++------------- src/room/Room.ts | 1 + 4 files changed, 41 insertions(+), 25 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4502343f83..bcc96ac413 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@livekit/protocol': link:../protocol/packages/javascript + importers: .: @@ -12,8 +15,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.39.3 - version: 1.39.3 + specifier: link:../protocol/packages/javascript + version: link:../protocol/packages/javascript '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1035,9 +1038,6 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.39.3': - resolution: {integrity: sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==} - '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -4794,10 +4794,6 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.39.3': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.23.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..dcd8ec42c8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +overrides: + '@livekit/protocol': link:../protocol/packages/javascript diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index f550aacc9c..1ba6182f86 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -1,9 +1,11 @@ import { ConnectRequest, ConnectResponse, - Signalv2ClientEnvelope, + Envelope, + Fragment, Signalv2ClientMessage, Signalv2ServerMessage, + Signalv2WireMessage, } from '@livekit/protocol'; export interface ITransportOptions { @@ -12,11 +14,6 @@ export interface ITransportOptions { connectRequest: ConnectRequest; } -type ValidServerMessage = Exclude< - NonNullable, - { case: 'fragment' } | { case: 'envelope' } | { case: undefined } ->; - export interface ITransportConnection { connectResponse: ConnectResponse; readableStream: ReadableStream; @@ -39,6 +36,8 @@ export class HybridSignalTransport implements ITransport { abortController: AbortController; + private fragmentBuffer: Map> = new Map(); + constructor(peerConnection: RTCPeerConnection) { this.pc = peerConnection; this.abortController = new AbortController(); @@ -70,16 +69,34 @@ export class HybridSignalTransport implements ITransport { const readableStream = new ReadableStream({ start: (controller) => { dc.addEventListener('message', (event: MessageEvent) => { - const serverMessage = Signalv2ServerMessage.fromBinary(new Uint8Array(event.data)); - switch (serverMessage.message.case) { - case 'envelope': - controller.enqueue(serverMessage.message.value); - break; - case 'connectResponse': - controller.enqueue(serverMessage.message.value); - break; - default: - throw new Error(`Unknown server message type: ${serverMessage.message.case}`); + const serverMessage = Signalv2WireMessage.fromBinary(new Uint8Array(event.data)); + if (serverMessage.message.case === 'envelope') { + for (const message of serverMessage.message.value.serverMessages) { + controller.enqueue(message); + } + } else if (serverMessage.message.case === 'fragment') { + const fragment = serverMessage.message.value; + const buffer = + this.fragmentBuffer.get(fragment.packetId) || + new Array(fragment.numFragments).fill(null); + buffer[fragment.fragmentNumber] = fragment; + this.fragmentBuffer.set(fragment.packetId, buffer); + if (buffer.every((f) => f !== null)) { + const rawEnvelope = Uint8Array.from(buffer.map((f) => f.data)); + if (rawEnvelope.byteLength !== fragment.totalSize) { + console.warn( + `Fragments of packet ${fragment.packetId} have incorrect size: ${rawEnvelope.byteLength} !== ${fragment.totalSize}`, + ); + return; + } + const envelope = Envelope.fromBinary(rawEnvelope); + this.fragmentBuffer.delete(fragment.packetId); + for (const message of envelope.serverMessages) { + controller.enqueue(message); + } + } + } else { + // TODO log warning } }); diff --git a/src/room/Room.ts b/src/room/Room.ts index 0a14924b96..acaba57e5a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -215,6 +215,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) */ constructor(options?: RoomOptions) { super(); + console.warn('Room constructor again'); this.setMaxListeners(100); this.remoteParticipants = new Map(); this.sidToIdentity = new Map(); From c5f2e34bdfc5feaa232fceda664c0fc4161d5f76 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 18 Jul 2025 09:39:41 +0200 Subject: [PATCH 05/12] envelope chunking --- src/api/SignalTransport.ts | 51 +++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index 1ba6182f86..cdc0fa7ab0 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -8,6 +8,8 @@ import { Signalv2WireMessage, } from '@livekit/protocol'; +const MAX_WIRE_MESSAGE_SIZE = 16_000; + export interface ITransportOptions { url: string; token: string; @@ -17,7 +19,7 @@ export interface ITransportOptions { export interface ITransportConnection { connectResponse: ConnectResponse; readableStream: ReadableStream; - writableStream: WritableStream; + writableStream: WritableStream>; } export interface ITransport { @@ -111,7 +113,7 @@ export class HybridSignalTransport implements ITransport { }); const writableStream = new WritableStream({ - async write(request: Signalv2ClientMessage) { + async write(requests: Array) { if (dc.readyState === 'closing' || dc.readyState === 'closed') { throw new Error('Signalling channel is closed'); } @@ -122,11 +124,48 @@ export class HybridSignalTransport implements ITransport { dc.addEventListener('error', reject); }); } - const envelope = new Signalv2ClientEnvelope({ - clientMessages: [request], + const envelope = new Envelope({ + clientMessages: requests, }); - // TODO chunking - dc.send(envelope.toBinary()); + const binaryEnvelope = envelope.toBinary(); + const envelopeSize = binaryEnvelope.byteLength; + if (envelopeSize > MAX_WIRE_MESSAGE_SIZE) { + console.info(`Sending fragmented envelope of ${envelopeSize} bytes`); + const numFragments = Math.ceil(envelopeSize / MAX_WIRE_MESSAGE_SIZE); + const fragments = []; + + for (let i = 0; i < numFragments; i++) { + fragments.push( + new Fragment({ + packetId: 0, + fragmentNumber: i, + data: binaryEnvelope.slice( + i * MAX_WIRE_MESSAGE_SIZE, + (i + 1) * MAX_WIRE_MESSAGE_SIZE, + ), + totalSize: envelopeSize, + numFragments, + }), + ); + } + for (const fragment of fragments) { + const wireMessage = new Signalv2WireMessage({ + message: { + case: 'fragment', + value: fragment, + }, + }); + dc.send(wireMessage.toBinary()); + } + } else { + const wireMessage = new Signalv2WireMessage({ + message: { + case: 'envelope', + value: envelope, + }, + }); + dc.send(wireMessage.toBinary()); + } }, close: () => { dc.close(); From 960687e8b70290cf9dc595935081ab76d9c7a414 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 18 Jul 2025 12:16:05 +0200 Subject: [PATCH 06/12] buffer low wait --- src/api/SignalTransport.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index cdc0fa7ab0..2610297ac2 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -1,3 +1,4 @@ +import { Mutex } from '@livekit/mutex'; import { ConnectRequest, ConnectResponse, @@ -7,6 +8,7 @@ import { Signalv2ServerMessage, Signalv2WireMessage, } from '@livekit/protocol'; +import { sleep } from '../room/utils'; const MAX_WIRE_MESSAGE_SIZE = 16_000; @@ -40,9 +42,27 @@ export class HybridSignalTransport implements ITransport { private fragmentBuffer: Map> = new Map(); + private bufferLowMutex: Mutex; + constructor(peerConnection: RTCPeerConnection) { this.pc = peerConnection; this.abortController = new AbortController(); + this.bufferLowMutex = new Mutex(); + } + + private get isBufferedAmountLow(): boolean { + if (!this.dc) { + return false; + } + return this.dc.bufferedAmount <= this.dc.bufferedAmountLowThreshold; + } + + async waitForBufferedAmountLow(): Promise { + const unlock = await this.bufferLowMutex.lock(); + while (!this.isBufferedAmountLow) { + sleep(10); + } + unlock(); } async connect({ url, token, connectRequest }: ITransportOptions): Promise { @@ -51,6 +71,7 @@ export class HybridSignalTransport implements ITransport { maxRetransmits: 0, }); this.dc = dc; + dc.bufferedAmountLowThreshold = 65535; const joinRequest = await fetch(`${url}/join`, { method: 'POST', @@ -113,7 +134,7 @@ export class HybridSignalTransport implements ITransport { }); const writableStream = new WritableStream({ - async write(requests: Array) { + write: async (requests: Array) => { if (dc.readyState === 'closing' || dc.readyState === 'closed') { throw new Error('Signalling channel is closed'); } @@ -155,6 +176,7 @@ export class HybridSignalTransport implements ITransport { value: fragment, }, }); + await this.waitForBufferedAmountLow(); dc.send(wireMessage.toBinary()); } } else { @@ -164,6 +186,7 @@ export class HybridSignalTransport implements ITransport { value: envelope, }, }); + await this.waitForBufferedAmountLow(); dc.send(wireMessage.toBinary()); } }, From df4f36e720c26e8aff3ff760477fd1f7ca160f50 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 18 Jul 2025 12:35:39 +0200 Subject: [PATCH 07/12] update messsages --- src/api/SignalAPI.ts | 62 +++++++++++++++++++++++--------------- src/api/SignalTransport.ts | 20 +++++++----- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index 914126dbe5..371c409b8b 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,19 +1,21 @@ import { Mutex } from '@livekit/mutex'; -import { ConnectionSettings, ConnectRequest, ConnectResponse, SessionDescription, SignalRequest, SignalResponse } from '@livekit/protocol'; +import { ConnectionSettings, ConnectRequest, ConnectResponse, Sequencer, SessionDescription, SignalResponse, Signalv2ClientMessage, Signalv2ServerMessage } from '@livekit/protocol'; import type { ITransport } from './SignalTransport'; import { Future, getClientInfo } from '../room/utils'; export class SignalAPI { - private writer?: WritableStreamDefaultWriter; + private writer?: WritableStreamDefaultWriter>; - private promiseMap = new Map>(); + private promiseMap = new Map>(); private offerId = 0; private transport: ITransport; + private sequenceNumber = 0; + constructor(transport: ITransport) { this.transport = transport; } @@ -33,7 +35,7 @@ export class SignalAPI { return connectResponse; } - async readLoop(readableStream: ReadableStream) { + async readLoop(readableStream: ReadableStream) { const reader = readableStream.getReader(); while (true) { try { @@ -60,33 +62,45 @@ export class SignalAPI { @atomic async sendOfferAndAwaitAnswer(offer: RTCSessionDescriptionInit): Promise { - const offerId = this.offerId++; - if(!this.writer) { - throw new Error('Writable stream not initialized'); - } + // const offerId = this.offerId++; + // if(!this.writer) { + // throw new Error('Writable stream not initialized'); + // } - const request = new SessionDescription({ - type: 'offer', - sdp: offer.sdp, - // id: offer.id, - }); + // const request = new SessionDescription({ + // type: 'offer', + // sdp: offer.sdp, + // // id: offer.id, + // }); - await this.writer.write(new SignalRequest({ - message: { case: 'offer', value: request }, - })); + // await this.writer.write([this.createClientRequest({ case: 'offer', value: request })]); - const future = new Future(); - // we want an answer for this offer so we queue up a future for it - this.promiseMap.set(getResponseKey('answer', offerId), future); - const answerResponse = await future.promise; + // const future = new Future(); + // // we want an answer for this offer so we queue up a future for it + // this.promiseMap.set(getResponseKey('answer', offerId), future); + // const answerResponse = await future.promise; - if(answerResponse.message.case === 'answer') { - return answerResponse.message.value; - } + // if(answerResponse.message.case === 'answer') { + // return answerResponse.message.value; + // } throw new Error('Answer not found'); } + private getNextSequencer(): Sequencer { + return new Sequencer({ + messageId: this.sequenceNumber++, + }); + } + + + createClientRequest(request: Signalv2ClientMessage['message']): Signalv2ClientMessage { + return new Signalv2ClientMessage({ + sequencer: this.getNextSequencer(), + message: request, + }); + } + // @loggedMethod async reconnect(): Promise { //return this.transport.reconnect(); @@ -133,4 +147,4 @@ function atomic(originalMethod: any) { function getResponseKey(requestType: SignalResponse['message']['case'], requestId: number) { return `${requestType}-${requestId}`; -} \ No newline at end of file +} diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index 2610297ac2..f3f8a88407 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -11,6 +11,7 @@ import { import { sleep } from '../room/utils'; const MAX_WIRE_MESSAGE_SIZE = 16_000; +const BUFFER_LOW_THRESHOLD = 65535; export interface ITransportOptions { url: string; @@ -33,10 +34,10 @@ export interface ITransportFactory { create(): Promise; } -export class HybridSignalTransport implements ITransport { +export class DCSignalTransport implements ITransport { private readonly pc: RTCPeerConnection; - private dc?: RTCDataChannel; + private dc: RTCDataChannel; abortController: AbortController; @@ -46,18 +47,21 @@ export class HybridSignalTransport implements ITransport { constructor(peerConnection: RTCPeerConnection) { this.pc = peerConnection; + this.dc = this.pc.createDataChannel('signal', { + ordered: true, + maxRetransmits: 0, + }); + this.dc.bufferedAmountLowThreshold = BUFFER_LOW_THRESHOLD; this.abortController = new AbortController(); this.bufferLowMutex = new Mutex(); } private get isBufferedAmountLow(): boolean { - if (!this.dc) { - return false; - } return this.dc.bufferedAmount <= this.dc.bufferedAmountLowThreshold; } async waitForBufferedAmountLow(): Promise { + // mutex is used to prevent race condition when multiple writes are happening at the same time const unlock = await this.bufferLowMutex.lock(); while (!this.isBufferedAmountLow) { sleep(10); @@ -70,8 +74,6 @@ export class HybridSignalTransport implements ITransport { ordered: true, maxRetransmits: 0, }); - this.dc = dc; - dc.bufferedAmountLowThreshold = 65535; const joinRequest = await fetch(`${url}/join`, { method: 'POST', @@ -130,6 +132,10 @@ export class HybridSignalTransport implements ITransport { dc.addEventListener('closed', () => { controller.close(); }); + + dc.addEventListener('open', () => { + console.info('Signal channel opened'); + }); }, }); From 8b921def14bb82b03f1e0ec3aac505451683b6e7 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 18 Jul 2025 15:19:09 +0200 Subject: [PATCH 08/12] http signal working --- src/api/SignalAPI.ts | 39 +++------------------ src/api/SignalTransport.ts | 72 +++++++++++++++++++++++++++++++++++--- src/api/decorators.ts | 32 +++++++++++++++++ src/room/RTCEngine.ts | 13 +++++-- 4 files changed, 115 insertions(+), 41 deletions(-) create mode 100644 src/api/decorators.ts diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index 371c409b8b..8522e22170 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,7 +1,7 @@ -import { Mutex } from '@livekit/mutex'; import { ConnectionSettings, ConnectRequest, ConnectResponse, Sequencer, SessionDescription, SignalResponse, Signalv2ClientMessage, Signalv2ServerMessage } from '@livekit/protocol'; import type { ITransport } from './SignalTransport'; import { Future, getClientInfo } from '../room/utils'; +import { atomic } from './decorators'; export class SignalAPI { @@ -29,7 +29,10 @@ export class SignalAPI { autoSubscribe: true, }) }); - const { readableStream, writableStream, connectResponse } = await this.transport.connect({ url, token, connectRequest }); + + const clientRequest = this.createClientRequest({ case: 'connectRequest', value: connectRequest }); + + const { readableStream, writableStream, connectResponse } = await this.transport.connect({ url, token, clientRequest }); this.readLoop(readableStream); this.writer = writableStream.getWriter(); return connectResponse; @@ -113,38 +116,6 @@ export class SignalAPI { } -// function loggedMethod( -// target: (this: This, ...args: Args) => Return, -// context: ClassMethodDecoratorContext Return> -// ) { -// const methodName = String(context.name); - -// function replacementMethod(this: This, ...args: Args): Return { -// console.debug(`LOG: Entering method '${methodName}'.`) -// const result = target.call(this, ...args); -// console.debug(`LOG: Exiting method '${methodName}'.`) -// return result; -// } - -// return replacementMethod; -// } - -function atomic(originalMethod: any) { - const mutex = new Mutex(); - - async function replacementMethod(this: any, ...args: any[]) { - const unlock = await mutex.lock(); - try { - const result = await originalMethod.call(this, ...args); - return result; - } finally { - unlock(); - } - } - - return replacementMethod; -} - function getResponseKey(requestType: SignalResponse['message']['case'], requestId: number) { return `${requestType}-${requestId}`; } diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index f3f8a88407..3a967d87df 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -13,10 +13,17 @@ import { sleep } from '../room/utils'; const MAX_WIRE_MESSAGE_SIZE = 16_000; const BUFFER_LOW_THRESHOLD = 65535; +export enum SignalConnectionState { + Connecting, + Connected, + Reconnecting, + Disconnected, +} + export interface ITransportOptions { url: string; token: string; - connectRequest: ConnectRequest; + clientRequest: Signalv2ClientMessage; } export interface ITransportConnection { @@ -28,6 +35,7 @@ export interface ITransportConnection { export interface ITransport { connect(options: ITransportOptions): Promise; close(): Promise; + currentState: SignalConnectionState; } export interface ITransportFactory { @@ -45,8 +53,15 @@ export class DCSignalTransport implements ITransport { private bufferLowMutex: Mutex; + private state: SignalConnectionState = SignalConnectionState.Connecting; + + get currentState(): SignalConnectionState { + return this.state; + } + constructor(peerConnection: RTCPeerConnection) { this.pc = peerConnection; + this.pc.addEventListener('iceconnectionstatechange', this.handleICEConnectionStateChange); this.dc = this.pc.createDataChannel('signal', { ordered: true, maxRetransmits: 0, @@ -56,6 +71,27 @@ export class DCSignalTransport implements ITransport { this.bufferLowMutex = new Mutex(); } + private handleICEConnectionStateChange(event: Event) { + const pc = event.target as RTCPeerConnection; + switch (pc.iceConnectionState) { + case 'new': + case 'checking': + this.state = SignalConnectionState.Connecting; + break; + case 'connected': + case 'completed': + this.state = SignalConnectionState.Connected; + break; + case 'disconnected': + this.state = SignalConnectionState.Reconnecting; + break; + case 'failed': + case 'closed': + this.state = SignalConnectionState.Disconnected; + break; + } + } + private get isBufferedAmountLow(): boolean { return this.dc.bufferedAmount <= this.dc.bufferedAmountLowThreshold; } @@ -69,28 +105,56 @@ export class DCSignalTransport implements ITransport { unlock(); } - async connect({ url, token, connectRequest }: ITransportOptions): Promise { + async connect({ url, token, clientRequest }: ITransportOptions): Promise { const dc = this.pc.createDataChannel('signal', { ordered: true, maxRetransmits: 0, }); - const joinRequest = await fetch(`${url}/join`, { + const connectRequest = new Signalv2WireMessage({ + message: { + case: 'envelope', + value: new Envelope({ + clientMessages: [clientRequest], + }), + }, + }); + + const joinRequest = await fetch(`${url}/rtc/v2`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-protobuf', }, body: connectRequest.toBinary(), signal: this.abortController.signal, }); + if (!joinRequest.ok) { + console.warn(`Failed to join room: ${joinRequest.statusText}`); + } + const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); - const connectResponse = ConnectResponse.fromBinary( + const signalResponse = Signalv2WireMessage.fromBinary( new Uint8Array(await joinRequest.arrayBuffer()), ); + if (signalResponse.message.case !== 'envelope') { + throw new Error('Invalid response from server'); + } + const envelope = signalResponse.message.value; + + if ( + envelope.serverMessages.length !== 1 || + envelope.serverMessages[0].message.case !== 'connectResponse' + ) { + throw new Error('Invalid response from server'); + } + + const connectResponse = envelope.serverMessages[0].message.value; + const readableStream = new ReadableStream({ start: (controller) => { dc.addEventListener('message', (event: MessageEvent) => { diff --git a/src/api/decorators.ts b/src/api/decorators.ts new file mode 100644 index 0000000000..59db57505b --- /dev/null +++ b/src/api/decorators.ts @@ -0,0 +1,32 @@ +// export function loggedMethod( +// target: (this: This, ...args: Args) => Return, +// context: ClassMethodDecoratorContext Return> +// ) { +// const methodName = String(context.name); +import { Mutex } from '@livekit/mutex'; + +// function replacementMethod(this: This, ...args: Args): Return { +// console.debug(`LOG: Entering method '${methodName}'.`) +// const result = target.call(this, ...args); +// console.debug(`LOG: Exiting method '${methodName}'.`) +// return result; +// } + +// return replacementMethod; +// } + +export function atomic(originalMethod: any) { + const mutex = new Mutex(); + + async function replacementMethod(this: any, ...args: any[]) { + const unlock = await mutex.lock(); + try { + const result = await originalMethod.call(this, ...args); + return result; + } finally { + unlock(); + } + } + + return replacementMethod; +} diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index fe2eaf8b12..e5cc404339 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -9,7 +9,7 @@ import { DataPacket, DataPacket_Kind, DisconnectReason, - type JoinResponse, + JoinResponse, type LeaveRequest, LeaveRequest_Action, ParticipantInfo, @@ -37,12 +37,14 @@ import { import { EventEmitter } from 'events'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; +import { SignalAPI } from '../api/SignalAPI'; import type { SignalOptions } from '../api/SignalClient'; import { SignalClient, SignalConnectionState, toProtoSessionDescription, } from '../api/SignalClient'; +import { DCSignalTransport } from '../api/SignalTransport'; import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomOptions } from '../options'; import { DataPacketBuffer } from '../utils/dataPacketBuffer'; @@ -98,6 +100,8 @@ enum PCState { export default class RTCEngine extends (EventEmitter as new () => TypedEventEmitter) { client: SignalClient; + signalAPI: SignalAPI; + rtcConfig: RTCConfiguration = {}; peerConnectionTimeout: number = roomConnectOptionDefaults.peerConnectionTimeout; @@ -201,6 +205,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit loggerContextCb: () => this.logContext, }; this.client = new SignalClient(undefined, this.loggerOptions); + this.signalAPI = new SignalAPI(new DCSignalTransport(new RTCPeerConnection())); this.client.signalLatency = this.options.expSignalLatency; this.reconnectPolicy = this.options.reconnectPolicy; this.registerOnLineListener(); @@ -249,10 +254,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.joinAttempts += 1; this.setupSignalClientCallbacks(); - const joinResponse = await this.client.join(url, token, opts, abortSignal); + const connectResponse = await this.signalAPI.join(url, token); + const joinResponse = new JoinResponse({ + ...connectResponse, + }); this._isClosed = false; this.latestJoinResponse = joinResponse; - this.subscriberPrimary = joinResponse.subscriberPrimary; if (!this.pcManager) { await this.configure(joinResponse); From 72bb0b18d029ad004df874bbc5aa27e9fbc517e0 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 23 Jul 2025 09:56:19 +0200 Subject: [PATCH 09/12] last remote seq! --- src/api/SignalAPI.ts | 11 +++++++---- src/api/SignalTransport.ts | 10 +++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index 8522e22170..b97d19181f 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -16,6 +16,8 @@ export class SignalAPI { private sequenceNumber = 0; + private latestRemoteSequenceNumber = 0; + constructor(transport: ITransport) { this.transport = transport; } @@ -47,8 +49,8 @@ export class SignalAPI { if(!value) { continue; } - // @ts-ignore - const responseKey = getResponseKey(value.message.case, value.message.value!.id as number); + this.latestRemoteSequenceNumber = value.sequencer!.messageId; + const responseKey = getResponseKey(value.message!.case, value.sequencer!.messageId); const future = this.promiseMap.get(responseKey); if (future) { future.resolve?.(value); @@ -93,6 +95,7 @@ export class SignalAPI { private getNextSequencer(): Sequencer { return new Sequencer({ messageId: this.sequenceNumber++, + lastProcessedRemoteMessageId: this.latestRemoteSequenceNumber, }); } @@ -116,6 +119,6 @@ export class SignalAPI { } -function getResponseKey(requestType: SignalResponse['message']['case'], requestId: number) { - return `${requestType}-${requestId}`; +function getResponseKey(requestType: Signalv2ServerMessage['message']['case'], messageId: number) { + return `${requestType}-${messageId}`; } diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index 3a967d87df..f226d61590 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -1,6 +1,5 @@ import { Mutex } from '@livekit/mutex'; import { - ConnectRequest, ConnectResponse, Envelope, Fragment, @@ -144,16 +143,13 @@ export class DCSignalTransport implements ITransport { if (signalResponse.message.case !== 'envelope') { throw new Error('Invalid response from server'); } - const envelope = signalResponse.message.value; + const connectEnvelope = signalResponse.message.value; - if ( - envelope.serverMessages.length !== 1 || - envelope.serverMessages[0].message.case !== 'connectResponse' - ) { + if (connectEnvelope.serverMessages[0].message.case !== 'connectResponse') { throw new Error('Invalid response from server'); } - const connectResponse = envelope.serverMessages[0].message.value; + const connectResponse = connectEnvelope.serverMessages[0].message.value; const readableStream = new ReadableStream({ start: (controller) => { From 48388ab6b10205cea5d917c6f011620ad009fb05 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 25 Jul 2025 13:22:20 +0200 Subject: [PATCH 10/12] add wire message converter with tests --- src/api/SignalAPI.ts | 4 +- src/api/SignalTransport.ts | 87 +---- src/api/WireMessageConverter.test.ts | 497 +++++++++++++++++++++++++++ src/api/WireMessageConverter.ts | 96 ++++++ src/{api => }/decorators.ts | 0 src/room/agent.ts | 26 ++ 6 files changed, 637 insertions(+), 73 deletions(-) create mode 100644 src/api/WireMessageConverter.test.ts create mode 100644 src/api/WireMessageConverter.ts rename src/{api => }/decorators.ts (100%) create mode 100644 src/room/agent.ts diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index b97d19181f..07d8ebd858 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,7 +1,7 @@ -import { ConnectionSettings, ConnectRequest, ConnectResponse, Sequencer, SessionDescription, SignalResponse, Signalv2ClientMessage, Signalv2ServerMessage } from '@livekit/protocol'; +import { ConnectionSettings, ConnectRequest, ConnectResponse, Sequencer, SessionDescription, Signalv2ClientMessage, Signalv2ServerMessage } from '@livekit/protocol'; import type { ITransport } from './SignalTransport'; import { Future, getClientInfo } from '../room/utils'; -import { atomic } from './decorators'; +import { atomic } from '../decorators'; export class SignalAPI { diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index f226d61590..0c74420e8c 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -8,8 +8,8 @@ import { Signalv2WireMessage, } from '@livekit/protocol'; import { sleep } from '../room/utils'; +import { WireMessageConverter } from './WireMessageConverter'; -const MAX_WIRE_MESSAGE_SIZE = 16_000; const BUFFER_LOW_THRESHOLD = 65535; export enum SignalConnectionState { @@ -48,7 +48,7 @@ export class DCSignalTransport implements ITransport { abortController: AbortController; - private fragmentBuffer: Map> = new Map(); + private wireMessageConverter: WireMessageConverter; private bufferLowMutex: Mutex; @@ -68,6 +68,7 @@ export class DCSignalTransport implements ITransport { this.dc.bufferedAmountLowThreshold = BUFFER_LOW_THRESHOLD; this.abortController = new AbortController(); this.bufferLowMutex = new Mutex(); + this.wireMessageConverter = new WireMessageConverter(); } private handleICEConnectionStateChange(event: Event) { @@ -133,9 +134,6 @@ export class DCSignalTransport implements ITransport { console.warn(`Failed to join room: ${joinRequest.statusText}`); } - const offer = await this.pc.createOffer(); - await this.pc.setLocalDescription(offer); - const signalResponse = Signalv2WireMessage.fromBinary( new Uint8Array(await joinRequest.arrayBuffer()), ); @@ -145,43 +143,25 @@ export class DCSignalTransport implements ITransport { } const connectEnvelope = signalResponse.message.value; - if (connectEnvelope.serverMessages[0].message.case !== 'connectResponse') { + const [connectResponseMessage, ...initialServerMessages] = connectEnvelope.serverMessages; + if (!connectResponseMessage || connectResponseMessage.message.case !== 'connectResponse') { throw new Error('Invalid response from server'); } - - const connectResponse = connectEnvelope.serverMessages[0].message.value; + const connectResponse = connectResponseMessage.message.value; const readableStream = new ReadableStream({ start: (controller) => { + for (const message of initialServerMessages) { + controller.enqueue(message); + } + dc.addEventListener('message', (event: MessageEvent) => { const serverMessage = Signalv2WireMessage.fromBinary(new Uint8Array(event.data)); - if (serverMessage.message.case === 'envelope') { - for (const message of serverMessage.message.value.serverMessages) { + const envelope = this.wireMessageConverter.wireMessageToEnvelope(serverMessage); + if (envelope) { + for (const message of envelope.serverMessages) { controller.enqueue(message); } - } else if (serverMessage.message.case === 'fragment') { - const fragment = serverMessage.message.value; - const buffer = - this.fragmentBuffer.get(fragment.packetId) || - new Array(fragment.numFragments).fill(null); - buffer[fragment.fragmentNumber] = fragment; - this.fragmentBuffer.set(fragment.packetId, buffer); - if (buffer.every((f) => f !== null)) { - const rawEnvelope = Uint8Array.from(buffer.map((f) => f.data)); - if (rawEnvelope.byteLength !== fragment.totalSize) { - console.warn( - `Fragments of packet ${fragment.packetId} have incorrect size: ${rawEnvelope.byteLength} !== ${fragment.totalSize}`, - ); - return; - } - const envelope = Envelope.fromBinary(rawEnvelope); - this.fragmentBuffer.delete(fragment.packetId); - for (const message of envelope.serverMessages) { - controller.enqueue(message); - } - } - } else { - // TODO log warning } }); @@ -214,44 +194,8 @@ export class DCSignalTransport implements ITransport { const envelope = new Envelope({ clientMessages: requests, }); - const binaryEnvelope = envelope.toBinary(); - const envelopeSize = binaryEnvelope.byteLength; - if (envelopeSize > MAX_WIRE_MESSAGE_SIZE) { - console.info(`Sending fragmented envelope of ${envelopeSize} bytes`); - const numFragments = Math.ceil(envelopeSize / MAX_WIRE_MESSAGE_SIZE); - const fragments = []; - - for (let i = 0; i < numFragments; i++) { - fragments.push( - new Fragment({ - packetId: 0, - fragmentNumber: i, - data: binaryEnvelope.slice( - i * MAX_WIRE_MESSAGE_SIZE, - (i + 1) * MAX_WIRE_MESSAGE_SIZE, - ), - totalSize: envelopeSize, - numFragments, - }), - ); - } - for (const fragment of fragments) { - const wireMessage = new Signalv2WireMessage({ - message: { - case: 'fragment', - value: fragment, - }, - }); - await this.waitForBufferedAmountLow(); - dc.send(wireMessage.toBinary()); - } - } else { - const wireMessage = new Signalv2WireMessage({ - message: { - case: 'envelope', - value: envelope, - }, - }); + const wireMessages = this.wireMessageConverter.envelopeToWireMessages(envelope); + for (const wireMessage of wireMessages) { await this.waitForBufferedAmountLow(); dc.send(wireMessage.toBinary()); } @@ -275,5 +219,6 @@ export class DCSignalTransport implements ITransport { async close(): Promise { this.abortController.abort(); this.dc?.close(); + this.wireMessageConverter.clearFragmentBuffer(); } } diff --git a/src/api/WireMessageConverter.test.ts b/src/api/WireMessageConverter.test.ts new file mode 100644 index 0000000000..542cf29bb0 --- /dev/null +++ b/src/api/WireMessageConverter.test.ts @@ -0,0 +1,497 @@ +import { Envelope, Fragment, Signalv2ClientMessage, Signalv2WireMessage } from '@livekit/protocol'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WireMessageConverter } from './WireMessageConverter'; + +describe('WireMessageConverter', () => { + let converter: WireMessageConverter; + + beforeEach(() => { + converter = new WireMessageConverter(); + }); + + describe('wireMessageToEnvelope', () => { + it('should return envelope directly when wire message contains envelope', () => { + const testEnvelope = new Envelope({ + clientMessages: [], + }); + + const wireMessage = new Signalv2WireMessage({ + message: { + case: 'envelope', + value: testEnvelope, + }, + }); + + const result = converter.wireMessageToEnvelope(wireMessage); + + expect(result).toBe(testEnvelope); + }); + + it('should return null for incomplete fragments', () => { + const fragment = new Fragment({ + packetId: 1, + fragmentNumber: 1, + data: new Uint8Array([1, 2, 3]), + totalSize: 10, + numFragments: 3, + }); + + const wireMessage = new Signalv2WireMessage({ + message: { + case: 'fragment', + value: fragment, + }, + }); + + const result = converter.wireMessageToEnvelope(wireMessage); + + expect(result).toBeNull(); + }); + + it('should assemble fragments when all are received', () => { + const connectRequest = new Signalv2ClientMessage({ + message: { + case: 'connectRequest', + value: { + metadata: 'test-metadata', + }, + }, + }); + + const testEnvelope = new Envelope({ + clientMessages: [connectRequest], + }); + const binaryEnvelope = testEnvelope.toBinary(); + + // Create fragments + const fragment1 = new Fragment({ + packetId: 1, + fragmentNumber: 1, + data: binaryEnvelope.slice(0, Math.ceil(binaryEnvelope.length / 2)), + totalSize: binaryEnvelope.byteLength, + numFragments: 2, + }); + + const fragment2 = new Fragment({ + packetId: 1, + fragmentNumber: 2, + data: binaryEnvelope.slice(Math.ceil(binaryEnvelope.length / 2)), + totalSize: binaryEnvelope.byteLength, + numFragments: 2, + }); + + const wireMessage1 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment1 }, + }); + + const wireMessage2 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment2 }, + }); + + // First fragment should return null + const result1 = converter.wireMessageToEnvelope(wireMessage1); + expect(result1).toBeNull(); + + // Second fragment should return assembled envelope + const result2 = converter.wireMessageToEnvelope(wireMessage2); + expect(result2).not.toBeNull(); + expect(result2?.clientMessages).toEqual([connectRequest]); + }); + + it('should handle fragments received out of order', () => { + const testEnvelope = new Envelope({ + clientMessages: [], + }); + const binaryEnvelope = testEnvelope.toBinary(); + + // Create fragments + const fragment1 = new Fragment({ + packetId: 2, + fragmentNumber: 1, + data: binaryEnvelope.slice(0, Math.ceil(binaryEnvelope.length / 2)), + totalSize: binaryEnvelope.byteLength, + numFragments: 2, + }); + + const fragment2 = new Fragment({ + packetId: 2, + fragmentNumber: 2, + data: binaryEnvelope.slice(Math.ceil(binaryEnvelope.length / 2)), + totalSize: binaryEnvelope.byteLength, + numFragments: 2, + }); + + const wireMessage1 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment1 }, + }); + + const wireMessage2 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment2 }, + }); + + // Receive second fragment first + const result1 = converter.wireMessageToEnvelope(wireMessage2); + expect(result1).toBeNull(); + + // Receive first fragment second + const result2 = converter.wireMessageToEnvelope(wireMessage1); + expect(result2).not.toBeNull(); + expect(result2?.clientMessages).toEqual([]); + }); + + it('should return null and clear buffer when fragment total size is incorrect', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const fragment1 = new Fragment({ + packetId: 3, + fragmentNumber: 1, + data: new Uint8Array([1, 2, 3]), + totalSize: 10, // Incorrect total size + numFragments: 2, + }); + + const fragment2 = new Fragment({ + packetId: 3, + fragmentNumber: 2, + data: new Uint8Array([4, 5, 6]), + totalSize: 10, // Incorrect total size + numFragments: 2, + }); + + const wireMessage1 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment1 }, + }); + + const wireMessage2 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment2 }, + }); + + converter.wireMessageToEnvelope(wireMessage1); + const result = converter.wireMessageToEnvelope(wireMessage2); + + expect(result).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Fragments of packet 3 have incorrect size'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should handle multiple fragment sets simultaneously', () => { + const testEnvelope1 = new Envelope({ + clientMessages: [], + }); + const testEnvelope2 = new Envelope({ + clientMessages: [], + }); + + const binaryEnvelope1 = testEnvelope1.toBinary(); + const binaryEnvelope2 = testEnvelope2.toBinary(); + + // Create fragments for first envelope (packet ID 1) + const frag1Part1 = new Fragment({ + packetId: 1, + fragmentNumber: 1, + data: binaryEnvelope1.slice(0, Math.ceil(binaryEnvelope1.length / 2)), + totalSize: binaryEnvelope1.byteLength, + numFragments: 2, + }); + + // Create fragments for second envelope (packet ID 2) + const frag2Part1 = new Fragment({ + packetId: 2, + fragmentNumber: 1, + data: binaryEnvelope2.slice(0, Math.ceil(binaryEnvelope2.length / 2)), + totalSize: binaryEnvelope2.byteLength, + numFragments: 2, + }); + + // Process first fragments + const result1 = converter.wireMessageToEnvelope( + new Signalv2WireMessage({ + message: { case: 'fragment', value: frag1Part1 }, + }), + ); + expect(result1).toBeNull(); + + const result2 = converter.wireMessageToEnvelope( + new Signalv2WireMessage({ + message: { case: 'fragment', value: frag2Part1 }, + }), + ); + expect(result2).toBeNull(); + + // Complete first envelope + const frag1Part2 = new Fragment({ + packetId: 1, + fragmentNumber: 2, + data: binaryEnvelope1.slice(Math.ceil(binaryEnvelope1.length / 2)), + totalSize: binaryEnvelope1.byteLength, + numFragments: 2, + }); + + const result3 = converter.wireMessageToEnvelope( + new Signalv2WireMessage({ + message: { case: 'fragment', value: frag1Part2 }, + }), + ); + expect(result3?.clientMessages).toEqual([]); + }); + + it('should return null for wire messages with unknown message case', () => { + const wireMessage = new Signalv2WireMessage({ + message: { + case: undefined, + value: undefined, + }, + }); + + const result = converter.wireMessageToEnvelope(wireMessage); + expect(result).toBeNull(); + }); + }); + + describe('envelopeToWireMessages', () => { + it('should return single wire message for small envelope', () => { + const testEnvelope = new Envelope({ + clientMessages: [], + }); + + const wireMessages = converter.envelopeToWireMessages(testEnvelope); + + expect(wireMessages).toHaveLength(1); + expect(wireMessages[0].message.case).toBe('envelope'); + expect(wireMessages[0].message.value).toBe(testEnvelope); + }); + + it('should fragment large envelope into multiple wire messages', () => { + const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + // Create a large envelope (larger than 16,000 bytes) + const largeEnvelope = new Envelope({ + clientMessages: [], + }); + + // Override toBinary to return large data + largeEnvelope.toBinary = vi.fn(() => new Uint8Array(20000).fill(42)); + + const wireMessages = converter.envelopeToWireMessages(largeEnvelope); + + expect(wireMessages.length).toBeGreaterThan(1); + expect(consoleInfoSpy).toHaveBeenCalledWith( + expect.stringContaining('Sending fragmented envelope'), + ); + + // Verify all wire messages are fragments + wireMessages.forEach((wireMessage) => { + expect(wireMessage.message.case).toBe('fragment'); + expect(wireMessage.message.value).toBeInstanceOf(Fragment); + }); + + // Verify fragment properties + const fragments = wireMessages.map((wm) => wm.message.value as Fragment); + const totalFragments = fragments.length; + + fragments.forEach((fragment, index) => { + expect(fragment.packetId).toBe(0); + expect(fragment.fragmentNumber).toBe(index + 1); + expect(fragment.numFragments).toBe(totalFragments); + expect(fragment.totalSize).toBe(20000); + }); + + consoleInfoSpy.mockRestore(); + }); + + it('should create fragments that can be reassembled correctly', () => { + // Create a moderately large envelope that will be fragmented + const connectRequest = new Signalv2ClientMessage({ + message: { + case: 'connectRequest', + value: { + metadata: new Array(20_000).fill('a').join(''), + }, + }, + }); + const testEnvelope = new Envelope({ + clientMessages: [connectRequest], + }); + + // Fragment the envelope + const wireMessages = converter.envelopeToWireMessages(testEnvelope); + expect(wireMessages.length).toBeGreaterThan(1); + console.log('wireMessages', wireMessages); + // Create new converter to test reassembly + const reassemblyConverter = new WireMessageConverter(); + + // Feed fragments back to converter + let reassembledEnvelope: Envelope | null = null; + for (const wireMessage of wireMessages) { + const result = reassemblyConverter.wireMessageToEnvelope(wireMessage); + if (result !== null) { + reassembledEnvelope = result; + } + } + + expect(reassembledEnvelope).not.toBeNull(); + expect(reassembledEnvelope?.clientMessages).toEqual([connectRequest]); + }); + + it('should handle exactly MAX_WIRE_MESSAGE_SIZE envelope', () => { + // Create envelope that's exactly under the size limit + const envelope = new Envelope({ + clientMessages: [], + }); + + const wireMessages = converter.envelopeToWireMessages(envelope); + + // Should be a single message since it's not over the limit + expect(wireMessages).toHaveLength(1); + expect(wireMessages[0].message.case).toBe('envelope'); + }); + + it('should handle empty envelope', () => { + const emptyEnvelope = new Envelope({ + clientMessages: [], + }); + + const wireMessages = converter.envelopeToWireMessages(emptyEnvelope); + + expect(wireMessages).toHaveLength(1); + expect(wireMessages[0].message.case).toBe('envelope'); + expect(wireMessages[0].message.value).toBe(emptyEnvelope); + }); + + it('should create fragments with sequential packet IDs', () => { + const envelope = new Envelope({ + clientMessages: [], + }); + + // Override toBinary to return large data + envelope.toBinary = vi.fn(() => new Uint8Array(20000).fill(1)); + + const wireMessages = converter.envelopeToWireMessages(envelope); + const fragments = wireMessages.map((wm) => wm.message.value as Fragment); + + // All fragments should have the same packet ID (0 in this implementation) + const packetIds = fragments.map((f) => f.packetId); + expect(new Set(packetIds).size).toBe(1); + expect(packetIds[0]).toBe(0); + }); + }); + + describe('clearFragmentBuffer', () => { + it('should clear all buffered fragments', () => { + // Add some fragments to the buffer + const fragment = new Fragment({ + packetId: 1, + fragmentNumber: 1, + data: new Uint8Array([1, 2, 3]), + totalSize: 6, + numFragments: 2, + }); + + const wireMessage = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment }, + }); + + // This should buffer the fragment + converter.wireMessageToEnvelope(wireMessage); + + // Clear the buffer + converter.clearFragmentBuffer(); + + // Adding the second fragment should now return null since buffer was cleared + const fragment2 = new Fragment({ + packetId: 1, + fragmentNumber: 2, + data: new Uint8Array([4, 5, 6]), + totalSize: 6, + numFragments: 2, + }); + + const wireMessage2 = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment2 }, + }); + + const result = converter.wireMessageToEnvelope(wireMessage2); + expect(result).toBeNull(); + }); + + it('should not affect direct envelope processing', () => { + const testEnvelope = new Envelope({ + clientMessages: [], + }); + + const wireMessage = new Signalv2WireMessage({ + message: { case: 'envelope', value: testEnvelope }, + }); + + converter.clearFragmentBuffer(); + + const result = converter.wireMessageToEnvelope(wireMessage); + expect(result).toBe(testEnvelope); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle fragments with zero data', () => { + const fragment = new Fragment({ + packetId: 1, + fragmentNumber: 1, + data: new Uint8Array(0), + totalSize: 0, + numFragments: 1, + }); + + const wireMessage = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragment }, + }); + + const result = converter.wireMessageToEnvelope(wireMessage); + expect(result).not.toBeNull(); + }); + + it('should handle very large fragment counts', () => { + const testEnvelope = new Envelope({ + clientMessages: [], + }); + const binaryEnvelope = testEnvelope.toBinary(); + + // Create many small fragments + const numFragments = 100; + const fragmentSize = Math.ceil(binaryEnvelope.length / numFragments); + + const fragments: Fragment[] = []; + for (let i = 1; i <= numFragments; i++) { + const start = i * fragmentSize; + const end = Math.min(start + fragmentSize, binaryEnvelope.length); + fragments.push( + new Fragment({ + packetId: 1, + fragmentNumber: i, + data: binaryEnvelope.slice(start, end), + totalSize: binaryEnvelope.byteLength, + numFragments, + }), + ); + } + + // Process all but the last fragment + for (let i = 0; i < fragments.length - 1; i++) { + const wireMessage = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragments[i] }, + }); + const result = converter.wireMessageToEnvelope(wireMessage); + expect(result).toBeNull(); + } + + // Process the last fragment + const lastWireMessage = new Signalv2WireMessage({ + message: { case: 'fragment', value: fragments[fragments.length - 1] }, + }); + const result = converter.wireMessageToEnvelope(lastWireMessage); + expect(result).not.toBeNull(); + expect(result?.clientMessages).toEqual([]); + }); + }); +}); diff --git a/src/api/WireMessageConverter.ts b/src/api/WireMessageConverter.ts new file mode 100644 index 0000000000..9f09a4f472 --- /dev/null +++ b/src/api/WireMessageConverter.ts @@ -0,0 +1,96 @@ +import { Envelope, Fragment, Signalv2WireMessage } from '@livekit/protocol'; + +const MAX_WIRE_MESSAGE_SIZE = 16_000; + +export class WireMessageConverter { + private readonly fragmentBuffer: Map> = new Map(); + + /** + * @param wireMessage - The wire message to convert to an envelope + * @returns The envelope of the wire message. If the wire message is a fragment, it will be buffered and returned only when the envelope completing fragment is passed. Immediately returns null if the wire message is a fragment and the envelope is not complete. + */ + wireMessageToEnvelope(wireMessage: Signalv2WireMessage): Envelope | null { + if (wireMessage.message.case === 'envelope') { + return wireMessage.message.value; + } else if (wireMessage.message.case === 'fragment') { + const fragment = wireMessage.message.value; + + const buffer = + this.fragmentBuffer.get(fragment.packetId) || + new Array(fragment.numFragments).fill(null); + buffer[fragment.fragmentNumber - 1] = fragment; + this.fragmentBuffer.set(fragment.packetId, buffer); + + if (buffer.every((f) => f !== null)) { + const totalDataReceived = buffer.reduce((acc, f) => acc + f.data.byteLength, 0); + if (totalDataReceived !== fragment.totalSize) { + console.warn( + `Fragments of packet ${fragment.packetId} have incorrect size: ${totalDataReceived} !== ${fragment.totalSize}`, + ); + console.log('buffer', buffer); + + this.fragmentBuffer.delete(fragment.packetId); + return null; + } + const rawEnvelope = new Uint8Array(totalDataReceived); + let offset = 0; + for (const f of buffer) { + rawEnvelope.set(f.data, offset); + offset += f.data.byteLength; + } + const envelope = Envelope.fromBinary(rawEnvelope); + this.fragmentBuffer.delete(fragment.packetId); + return envelope; + } + return null; + } + return null; + } + + clearFragmentBuffer(): void { + this.fragmentBuffer.clear(); + } + + /** + * @param envelope - The envelope to convert to wire messages + * @returns The wire messages + */ + envelopeToWireMessages(envelope: Envelope): Array { + const binaryEnvelope = envelope.toBinary(); + const envelopeSize = binaryEnvelope.byteLength; + if (envelopeSize > MAX_WIRE_MESSAGE_SIZE) { + console.info(`Sending fragmented envelope of ${envelopeSize} bytes`); + const numFragments = Math.ceil(envelopeSize / MAX_WIRE_MESSAGE_SIZE); + const fragments = []; + + for (let i = 0; i < numFragments; i++) { + fragments.push( + new Fragment({ + packetId: 0, + fragmentNumber: i + 1, + data: binaryEnvelope.slice(i * MAX_WIRE_MESSAGE_SIZE, (i + 1) * MAX_WIRE_MESSAGE_SIZE), + totalSize: envelopeSize, + numFragments, + }), + ); + } + return fragments.map( + (fragment) => + new Signalv2WireMessage({ + message: { + case: 'fragment', + value: fragment, + }, + }), + ); + } else { + const wireMessage = new Signalv2WireMessage({ + message: { + case: 'envelope', + value: envelope, + }, + }); + return [wireMessage]; + } + } +} diff --git a/src/api/decorators.ts b/src/decorators.ts similarity index 100% rename from src/api/decorators.ts rename to src/decorators.ts diff --git a/src/room/agent.ts b/src/room/agent.ts new file mode 100644 index 0000000000..2ab7410e06 --- /dev/null +++ b/src/room/agent.ts @@ -0,0 +1,26 @@ +import type { AgentState } from './attribute-typings'; +import RemoteParticipant from './participant/RemoteParticipant'; + +export interface Agent extends RemoteParticipant { + interrupt(): Promise; + sendContext(context: string): Promise; +} + +export interface AgentSession { + // connection + connect(): Promise; + disconnect(): Promise; + + // agent controls + interrupt(): Promise; + sendContext(context: string): Promise; + agent?: Agent; + + // local user controls + setMicrophoneEnabled(enabled: boolean): Promise; + setCameraEnabled(enabled: boolean): Promise; + + // messaging + sendMessage(message: Message): Promise; + messages: Message[]; +} From 17b6f1aab6f545c4d17038bdc9a05735103078e6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 4 Aug 2025 08:29:37 +0200 Subject: [PATCH 11/12] more wip --- src/api/SignalTransport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index 0c74420e8c..a81dcdf902 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -2,7 +2,6 @@ import { Mutex } from '@livekit/mutex'; import { ConnectResponse, Envelope, - Fragment, Signalv2ClientMessage, Signalv2ServerMessage, Signalv2WireMessage, From 35aac1baa4728d16e15ff1dc0e3e158860e537fa Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 7 Aug 2025 15:14:08 +0200 Subject: [PATCH 12/12] websocket stream --- src/api/SignalAPI.ts | 92 ++++++---- src/api/SignalTransport.ts | 335 ++++++++++++++++++------------------- src/api/WebSocketStream.ts | 90 ++++++++++ 3 files changed, 312 insertions(+), 205 deletions(-) create mode 100644 src/api/WebSocketStream.ts diff --git a/src/api/SignalAPI.ts b/src/api/SignalAPI.ts index 07d8ebd858..52a34b2799 100644 --- a/src/api/SignalAPI.ts +++ b/src/api/SignalAPI.ts @@ -1,4 +1,4 @@ -import { ConnectionSettings, ConnectRequest, ConnectResponse, Sequencer, SessionDescription, Signalv2ClientMessage, Signalv2ServerMessage } from '@livekit/protocol'; +import { SignalRequest, SignalResponse, JoinResponse } from '@livekit/protocol'; import type { ITransport } from './SignalTransport'; import { Future, getClientInfo } from '../room/utils'; import { atomic } from '../decorators'; @@ -6,9 +6,9 @@ import { atomic } from '../decorators'; export class SignalAPI { - private writer?: WritableStreamDefaultWriter>; + private writer?: WritableStreamDefaultWriter; - private promiseMap = new Map>(); + private promiseMap = new Map>(); private offerId = 0; @@ -23,40 +23,58 @@ export class SignalAPI { } @atomic - async join(url: string, token: string): Promise { - const connectRequest = new ConnectRequest({ - clientInfo: getClientInfo(), - connectionSettings: new ConnectionSettings({ - adaptiveStream: true, - autoSubscribe: true, - }) - }); - - const clientRequest = this.createClientRequest({ case: 'connectRequest', value: connectRequest }); - - const { readableStream, writableStream, connectResponse } = await this.transport.connect({ url, token, clientRequest }); + async join(url: string, token: string, connectOpts: ConnectOpts): Promise { + const clientInfo = getClientInfo(); + const { readableStream, writableStream } = await this.transport.connect({ url, token, clientInfo, connectOpts }); + const reader = readableStream.getReader(); + const { done, value } = await reader.read(); + reader.releaseLock(); + if(value?.message?.case !== 'join') { + throw new Error('Expected join response'); + } + if(done || !value) { + throw new Error('Connection closed without join response'); + } this.readLoop(readableStream); this.writer = writableStream.getWriter(); - return connectResponse; + + return value.message.value; } - async readLoop(readableStream: ReadableStream) { + async readLoop(readableStream: ReadableStream) { const reader = readableStream.getReader(); while (true) { try { const { done, value } = await reader.read(); - if(!value) { - continue; + if (done || !value) break; + + + const resolverId = getResolverId(value.message); + if(resolverId) { + const responseKey = getResponseKey(value.message.case, resolverId); + const future = this.promiseMap.get(responseKey); + if (future) { + future.resolve?.(value); + continue; + } } - this.latestRemoteSequenceNumber = value.sequencer!.messageId; - const responseKey = getResponseKey(value.message!.case, value.sequencer!.messageId); - const future = this.promiseMap.get(responseKey); - if (future) { - future.resolve?.(value); + + switch(value.message.case) { + case 'join': + case 'answer': + case 'requestResponse': + console.warn(`received ${value.message.case} these should all be handled by the promise map`); + break; + case 'leave': + value.message.value. + this.close(); + break; + default: + console.debug(`received unsupported message ${value.message.case} `); + break; } - if (done) break; } catch(e) { Array.from(this.promiseMap.values()).forEach(future => future.reject?.(e)); this.promiseMap.clear(); @@ -100,13 +118,6 @@ export class SignalAPI { } - createClientRequest(request: Signalv2ClientMessage['message']): Signalv2ClientMessage { - return new Signalv2ClientMessage({ - sequencer: this.getNextSequencer(), - message: request, - }); - } - // @loggedMethod async reconnect(): Promise { //return this.transport.reconnect(); @@ -114,11 +125,24 @@ export class SignalAPI { // @loggedMethod close() { - this.transport.close(); + return this.transport.disconnect(); } } -function getResponseKey(requestType: Signalv2ServerMessage['message']['case'], messageId: number) { +function getResponseKey(requestType: SignalResponse['message']['case'], messageId: number) { return `${requestType}-${messageId}`; } + + +function getResolverId(message: SignalResponse['message']) { + if(typeof message.value !== 'object') { + return null; + } + if('requestId' in message.value) { + return message.value.requestId; + } else if('id' in message.value) { + return message.value.id; + } + return null; +} \ No newline at end of file diff --git a/src/api/SignalTransport.ts b/src/api/SignalTransport.ts index a81dcdf902..5578881e00 100644 --- a/src/api/SignalTransport.ts +++ b/src/api/SignalTransport.ts @@ -1,223 +1,216 @@ -import { Mutex } from '@livekit/mutex'; -import { - ConnectResponse, - Envelope, - Signalv2ClientMessage, - Signalv2ServerMessage, - Signalv2WireMessage, -} from '@livekit/protocol'; -import { sleep } from '../room/utils'; -import { WireMessageConverter } from './WireMessageConverter'; - -const BUFFER_LOW_THRESHOLD = 65535; +import { ClientInfo, SignalRequest, SignalResponse } from '@livekit/protocol'; +import { WebSocketStream } from './WebsocketStream'; +import { atomic } from '../decorators'; +import { createRtcUrl, createValidateUrl } from './utils'; +import { isReactNative } from '../room/utils'; +import { ConnectionError, ConnectionErrorReason } from '../room/errors'; export enum SignalConnectionState { + Initial, Connecting, Connected, Reconnecting, + Disconnecting, Disconnected, } +// internal options +interface ConnectOpts extends SignalOptions { + /** internal */ + reconnect?: boolean; + /** internal */ + reconnectReason?: number; + /** internal */ + sid?: string; + /** internal */ + // joinRequest: JoinRequest; // TODO: add this back in +} + +// public options +export interface SignalOptions { + autoSubscribe: boolean; + adaptiveStream?: boolean; + maxRetries: number; + e2eeEnabled: boolean; + websocketTimeout: number; +} + export interface ITransportOptions { url: string; token: string; - clientRequest: Signalv2ClientMessage; + connectOpts: ConnectOpts; + clientInfo: ClientInfo; } export interface ITransportConnection { - connectResponse: ConnectResponse; - readableStream: ReadableStream; - writableStream: WritableStream>; + readableStream: ReadableStream; + writableStream: WritableStream; } export interface ITransport { connect(options: ITransportOptions): Promise; - close(): Promise; - currentState: SignalConnectionState; + disconnect(): Promise; + state: SignalConnectionState; } export interface ITransportFactory { create(): Promise; } -export class DCSignalTransport implements ITransport { - private readonly pc: RTCPeerConnection; - - private dc: RTCDataChannel; - - abortController: AbortController; +export class WSTransport implements ITransport { + private wsStream?: WebSocketStream; - private wireMessageConverter: WireMessageConverter; + private _state: SignalConnectionState; - private bufferLowMutex: Mutex; + constructor() { + this._state = SignalConnectionState.Initial; + } - private state: SignalConnectionState = SignalConnectionState.Connecting; + get state() { + return this._state; + } - get currentState(): SignalConnectionState { - return this.state; + private updateState(state: SignalConnectionState) { + this._state = state; } - constructor(peerConnection: RTCPeerConnection) { - this.pc = peerConnection; - this.pc.addEventListener('iceconnectionstatechange', this.handleICEConnectionStateChange); - this.dc = this.pc.createDataChannel('signal', { - ordered: true, - maxRetransmits: 0, - }); - this.dc.bufferedAmountLowThreshold = BUFFER_LOW_THRESHOLD; - this.abortController = new AbortController(); - this.bufferLowMutex = new Mutex(); - this.wireMessageConverter = new WireMessageConverter(); - } - - private handleICEConnectionStateChange(event: Event) { - const pc = event.target as RTCPeerConnection; - switch (pc.iceConnectionState) { - case 'new': - case 'checking': - this.state = SignalConnectionState.Connecting; - break; - case 'connected': - case 'completed': - this.state = SignalConnectionState.Connected; - break; - case 'disconnected': - this.state = SignalConnectionState.Reconnecting; - break; - case 'failed': - case 'closed': - this.state = SignalConnectionState.Disconnected; - break; + @atomic + async connect(options: ITransportOptions) { + this.updateState(SignalConnectionState.Connecting); + + const params = createConnectionParams(options.token, options.clientInfo, options.connectOpts); + const rtcUrl = createRtcUrl(options.url, params); + const validateUrl = createValidateUrl(rtcUrl); + + try { + + this.wsStream = new WebSocketStream(rtcUrl); + + + const connection = await this.wsStream.opened; + + this.wsStream.closed.catch((e) => { + console.error('encountered websocket error', e); + }).finally(() => { + this.updateState(SignalConnectionState.Disconnected); + this.wsStream = undefined; + }); + + this.updateState(SignalConnectionState.Connected); + + const requestEncoder = new ClientRequestEncoder(); + requestEncoder.readable.pipeTo(connection.writable); + + return { + readableStream: connection.readable.pipeThrough(new ServerResponseDecoder()), + writableStream: requestEncoder.writable, + }; + } catch (error) { + this.updateState(SignalConnectionState.Disconnecting); + const resp = await fetch(validateUrl); + this.updateState(SignalConnectionState.Disconnected); + if (resp.status.toFixed(0).startsWith('4')) { + const msg = await resp.text(); + throw new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status); + } else if (error instanceof Error) { + throw new ConnectionError( + `Encountered unknown websocket error during connection: ${error.name}: ${error.message}`, + ConnectionErrorReason.InternalError, + resp.status, + ); + } else { + throw error; + } } } - private get isBufferedAmountLow(): boolean { - return this.dc.bufferedAmount <= this.dc.bufferedAmountLowThreshold; + async disconnect(reason?: string) { + this.updateState(SignalConnectionState.Disconnecting); + await this.wsStream?.close({ reason }); + this.updateState(SignalConnectionState.Disconnected); } +} - async waitForBufferedAmountLow(): Promise { - // mutex is used to prevent race condition when multiple writes are happening at the same time - const unlock = await this.bufferLowMutex.lock(); - while (!this.isBufferedAmountLow) { - sleep(10); - } - unlock(); - } +class ServerResponseDecoder extends TransformStream { + constructor() { + super({ + transform(chunk, controller) { + let resp: SignalResponse; - async connect({ url, token, clientRequest }: ITransportOptions): Promise { - const dc = this.pc.createDataChannel('signal', { - ordered: true, - maxRetransmits: 0, - }); + if (typeof chunk === 'string') { + resp = SignalResponse.fromJson(JSON.parse(chunk), { ignoreUnknownFields: true }); + } else { + resp = SignalResponse.fromBinary(chunk); + } - const connectRequest = new Signalv2WireMessage({ - message: { - case: 'envelope', - value: new Envelope({ - clientMessages: [clientRequest], - }), + controller.enqueue(resp); }, }); + } +} - const joinRequest = await fetch(`${url}/rtc/v2`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/x-protobuf', +class ClientRequestEncoder extends TransformStream { + constructor() { + super({ + transform(chunk, controller) { + controller.enqueue(chunk.toBinary()); }, - body: connectRequest.toBinary(), - signal: this.abortController.signal, }); + } +} - if (!joinRequest.ok) { - console.warn(`Failed to join room: ${joinRequest.statusText}`); - } - - const signalResponse = Signalv2WireMessage.fromBinary( - new Uint8Array(await joinRequest.arrayBuffer()), - ); - - if (signalResponse.message.case !== 'envelope') { - throw new Error('Invalid response from server'); - } - const connectEnvelope = signalResponse.message.value; - - const [connectResponseMessage, ...initialServerMessages] = connectEnvelope.serverMessages; - if (!connectResponseMessage || connectResponseMessage.message.case !== 'connectResponse') { - throw new Error('Invalid response from server'); +function createConnectionParams( + token: string, + info: ClientInfo, + opts: ConnectOpts, +): URLSearchParams { + const params = new URLSearchParams(); + params.set('access_token', token); + + // opts + if (opts.reconnect) { + params.set('reconnect', '1'); + if (opts.sid) { + params.set('sid', opts.sid); } - const connectResponse = connectResponseMessage.message.value; - - const readableStream = new ReadableStream({ - start: (controller) => { - for (const message of initialServerMessages) { - controller.enqueue(message); - } - - dc.addEventListener('message', (event: MessageEvent) => { - const serverMessage = Signalv2WireMessage.fromBinary(new Uint8Array(event.data)); - const envelope = this.wireMessageConverter.wireMessageToEnvelope(serverMessage); - if (envelope) { - for (const message of envelope.serverMessages) { - controller.enqueue(message); - } - } - }); - - dc.addEventListener('error', (event: Event) => { - controller.error(event); - }); - - dc.addEventListener('closed', () => { - controller.close(); - }); - - dc.addEventListener('open', () => { - console.info('Signal channel opened'); - }); - }, - }); + } - const writableStream = new WritableStream({ - write: async (requests: Array) => { - if (dc.readyState === 'closing' || dc.readyState === 'closed') { - throw new Error('Signalling channel is closed'); - } + params.set('auto_subscribe', opts.autoSubscribe ? '1' : '0'); - if (dc.readyState !== 'open') { - await new Promise((resolve, reject) => { - dc.addEventListener('open', resolve); - dc.addEventListener('error', reject); - }); - } - const envelope = new Envelope({ - clientMessages: requests, - }); - const wireMessages = this.wireMessageConverter.envelopeToWireMessages(envelope); - for (const wireMessage of wireMessages) { - await this.waitForBufferedAmountLow(); - dc.send(wireMessage.toBinary()); - } - }, - close: () => { - dc.close(); - }, + // ClientInfo + params.set('sdk', isReactNative() ? 'reactnative' : 'js'); + params.set('version', info.version!); + params.set('protocol', info.protocol!.toString()); + if (info.deviceModel) { + params.set('device_model', info.deviceModel); + } + if (info.os) { + params.set('os', info.os); + } + if (info.osVersion) { + params.set('os_version', info.osVersion); + } + if (info.browser) { + params.set('browser', info.browser); + } + if (info.browserVersion) { + params.set('browser_version', info.browserVersion); + } - abort: () => { - dc.close(); - }, - }); + if (opts.adaptiveStream) { + params.set('adaptive_stream', '1'); + } - return { - connectResponse, - readableStream, - writableStream, - }; + if (opts.reconnectReason) { + params.set('reconnect_reason', opts.reconnectReason.toString()); } - async close(): Promise { - this.abortController.abort(); - this.dc?.close(); - this.wireMessageConverter.clearFragmentBuffer(); + // @ts-ignore + if (navigator.connection?.type) { + // @ts-ignore + params.set('network', navigator.connection.type); } + + return params; } + diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts new file mode 100644 index 0000000000..51f520e272 --- /dev/null +++ b/src/api/WebSocketStream.ts @@ -0,0 +1,90 @@ +/** + * This is a polyfill for the WebSocketStream API. + * source: https://github.com/CarterLi/websocketstream-polyfill + */ + +export interface WebSocketConnection { + readable: ReadableStream; + writable: WritableStream; + protocol: string; + extensions: string; +} + +export interface WebSocketCloseInfo { + closeCode?: number; + reason?: string; +} + +export interface WebSocketStreamOptions { + protocols?: string[]; + signal?: AbortSignal; +} + +/** + * [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) + * + * @see https://web.dev/websocketstream/ + */ +export class WebSocketStream { + readonly url: string; + + readonly opened: Promise>; + + readonly closed: Promise; + + readonly close: (closeInfo?: WebSocketCloseInfo) => void; + + constructor(url: string, options: WebSocketStreamOptions = {}) { + if (options.signal?.aborted) { + throw new DOMException('This operation was aborted', 'AbortError'); + } + + this.url = url; + + const ws = new WebSocket(url, options.protocols ?? []); + + const closeWithInfo = ({ closeCode: code, reason }: WebSocketCloseInfo = {}) => + ws.close(code, reason); + + this.opened = new Promise((resolve, reject) => { + ws.onopen = () => { + resolve({ + readable: new ReadableStream({ + start(controller) { + ws.onmessage = ({ data }) => controller.enqueue(data); + ws.onerror = (e) => controller.error(e); + }, + cancel: closeWithInfo, + }), + writable: new WritableStream({ + write(chunk) { + ws.send(chunk); + }, + abort() { + closeWithInfo({ closeCode: 1000, reason: 'Aborted' }); + }, + close: closeWithInfo, + }), + protocol: ws.protocol, + extensions: ws.extensions, + }); + ws.removeEventListener('error', reject); + }; + ws.addEventListener('error', reject); + }); + + this.closed = new Promise((resolve, reject) => { + ws.onclose = ({ code, reason }) => { + resolve({ closeCode: code, reason }); + ws.removeEventListener('error', reject); + }; + ws.addEventListener('error', reject); + }); + + if (options.signal) { + options.signal.onabort = () => ws.close(); + } + + this.close = closeWithInfo; + } +}