From fb3b3acc4c0ec8ea95476c6bb27f6626b5120484 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Mon, 15 Dec 2025 14:01:54 +0100 Subject: [PATCH 01/15] Support Cookies when authenticating new sockets --- packages/loro-protocol/yarn.lock | 1000 +++++++++++++++++ .../src/server/simple-server.ts | 26 +- .../tests/handshake-auth.test.ts | 70 ++ rust/Cargo.lock | 63 ++ rust/loro-websocket-server/Cargo.toml | 1 + rust/loro-websocket-server/src/lib.rs | 17 +- rust/loro-websocket-server/tests/e2e.rs | 8 +- .../tests/elo_accept_broadcast.rs | 2 +- .../tests/elo_fragment_reassembly.rs | 2 +- .../tests/handshake_auth.rs | 2 +- .../tests/handshake_cookies.rs | 66 ++ .../tests/join_denied.rs | 2 +- .../tests/join_snapshot_load.rs | 2 +- .../tests/readonly_receive.rs | 2 +- .../tests/reject_update_without_join.rs | 2 +- 15 files changed, 1251 insertions(+), 14 deletions(-) create mode 100644 packages/loro-protocol/yarn.lock create mode 100644 packages/loro-websocket/tests/handshake-auth.test.ts create mode 100644 rust/loro-websocket-server/tests/handshake_cookies.rs diff --git a/packages/loro-protocol/yarn.lock b/packages/loro-protocol/yarn.lock new file mode 100644 index 0000000..9d81878 --- /dev/null +++ b/packages/loro-protocol/yarn.lock @@ -0,0 +1,1000 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/generator@^7.28.3": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/parser@^7.28.3", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + +"@babel/types@^7.28.2", "@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@emnapi/core@^1.5.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4" + integrity sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.5.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" + integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + +"@jridgewell/gen-mapping@^0.3.12": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@napi-rs/wasm-runtime@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz#dcfea99a75f06209a235f3d941e3460a51e9b14c" + integrity sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw== + dependencies: + "@emnapi/core" "^1.5.0" + "@emnapi/runtime" "^1.5.0" + "@tybys/wasm-util" "^0.10.1" + +"@oxc-project/types@=0.98.0": + version "0.98.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.98.0.tgz#2deb27674e3ac0f76add4fd40a4887fd91cdf118" + integrity sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw== + +"@quansync/fs@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@quansync/fs/-/fs-0.1.5.tgz#8d89bc7add93f9b77e053ff8293d5ca82199fb1f" + integrity sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA== + dependencies: + quansync "^0.2.11" + +"@rolldown/binding-android-arm64@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.51.tgz#9cbe285955665eba6f29578b230352f1f22c3991" + integrity sha512-Ctn8FUXKWWQI9pWC61P1yumS9WjQtelNS9riHwV7oCkknPGaAry4o7eFx2KgoLMnI2BgFJYpW7Im8/zX3BuONg== + +"@rolldown/binding-darwin-arm64@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.51.tgz#b15f34e9082e08b14f9df12a5b5e9cd8958bd53b" + integrity sha512-EL1aRW2Oq15ShUEkBPsDtLMO8GTqfb/ktM/dFaVzXKQiEE96Ss6nexMgfgQrg8dGnNpndFyffVDb5IdSibsu1g== + +"@rolldown/binding-darwin-x64@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.51.tgz#27a8d3073c53c35d63077f1cf496db29bb9d2ea2" + integrity sha512-uGtYKlFen9pMIPvkHPWZVDtmYhMQi5g5Ddsndg1gf3atScKYKYgs5aDP4DhHeTwGXQglhfBG7lEaOIZ4UAIWww== + +"@rolldown/binding-freebsd-x64@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.51.tgz#17f2836fb45e0039901b0f8012637ef64ca1e95c" + integrity sha512-JRoVTQtHYbZj1P07JLiuTuXjiBtIa7ag7/qgKA6CIIXnAcdl4LrOf7nfDuHPJcuRKaP5dzecMgY99itvWfmUFQ== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.51.tgz#14f1310c66c1d1ddbf05c641605817ce05eeeb65" + integrity sha512-BKATVnpPZ0TYBW9XfDwyd4kPGgvf964HiotIwUgpMrFOFYWqpZ+9ONNzMV4UFAYC7Hb5C2qgYQk/qj2OnAd4RQ== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.51.tgz#eb53a6d73cda90e390afb99b80032bf166ef4d22" + integrity sha512-xLd7da5jkfbVsBCm1buIRdWtuXY8+hU3+6ESXY/Tk5X5DPHaifrUblhYDgmA34dQt6WyNC2kfXGgrduPEvDI6Q== + +"@rolldown/binding-linux-arm64-musl@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.51.tgz#528cd4aba1ff8032596a5ef73ccc634d53434714" + integrity sha512-EQFXTgHxxTzv3t5EmjUP/DfxzFYx9sMndfLsYaAY4DWF6KsK1fXGYsiupif6qPTViPC9eVmRm78q0pZU/kuIPg== + +"@rolldown/binding-linux-x64-gnu@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.51.tgz#df7987621f95e30d449e7e3293d14c1a5d5ef33f" + integrity sha512-p5P6Xpa68w3yFaAdSzIZJbj+AfuDnMDqNSeglBXM7UlJT14Q4zwK+rV+8Mhp9MiUb4XFISZtbI/seBprhkQbiQ== + +"@rolldown/binding-linux-x64-musl@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.51.tgz#da5e7a5980c24a234076330722600874e7c85f48" + integrity sha512-sNVVyLa8HB8wkFipdfz1s6i0YWinwpbMWk5hO5S+XAYH2UH67YzUT13gs6wZTKg2x/3gtgXzYnHyF5wMIqoDAw== + +"@rolldown/binding-openharmony-arm64@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.51.tgz#b200f52442a01beeba8b9b7f79ce0197b58e7ddc" + integrity sha512-e/JMTz9Q8+T3g/deEi8DK44sFWZWGKr9AOCW5e8C8SCVWzAXqYXAG7FXBWBNzWEZK0Rcwo9TQHTQ9Q0gXgdCaA== + +"@rolldown/binding-wasm32-wasi@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.51.tgz#f52796b9850aee7010144efe11b6fb3a6755d3c9" + integrity sha512-We3LWqSu6J9s5Y0MK+N7fUiiu37aBGPG3Pc347EoaROuAwkCS2u9xJ5dpIyLW4B49CIbS3KaPmn4kTgPb3EyPw== + dependencies: + "@napi-rs/wasm-runtime" "^1.0.7" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.51.tgz#aa5ae9a6bf19433c442c9d7e1eaadd94ced6015f" + integrity sha512-fj56buHRuMM+r/cb6ZYfNjNvO/0xeFybI6cTkTROJatdP4fvmQ1NS8D/Lm10FCSDEOkqIz8hK3TGpbAThbPHsA== + +"@rolldown/binding-win32-ia32-msvc@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.51.tgz#76c2f69bd11b6f06fc490719753236b26b2ec3a9" + integrity sha512-fkqEqaeEx8AySXiDm54b/RdINb3C0VovzJA3osMhZsbn6FoD73H0AOIiaVAtGr6x63hefruVKTX8irAm4Jkt2w== + +"@rolldown/binding-win32-x64-msvc@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.51.tgz#2b3cd4f7fc6bea58fef84ae72dd3bd0eee2dac2b" + integrity sha512-CWuLG/HMtrVcjKGa0C4GnuxONrku89g0+CsH8nT0SNhOtREXuzwgjIXNJImpE/A/DMf9JF+1Xkrq/YRr+F/rCg== + +"@rolldown/pluginutils@1.0.0-beta.51": + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.51.tgz#dd482c825c7af3c656c01cf1cbd6e0e3cb478ed9" + integrity sha512-51/8cNXMrqWqX3o8DZidhwz1uYq0BhHDDSfVygAND1Skx5s1TDw3APSSxCMcFFedwgqGcx34gRouwY+m404BBQ== + +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + +"@rollup/rollup-darwin-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" + integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== + +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +ansis@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.2.0.tgz#2e6e61c46b11726ac67f78785385618b9e658780" + integrity sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-kit@^2.1.2: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-2.2.0.tgz#6d9a298acefef5bdfc5a0fa51d94d1334ef2e671" + integrity sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw== + dependencies: + "@babel/parser" "^7.28.5" + pathe "^2.0.3" + +birpc@^2.5.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.8.0.tgz#064c90bda7912ef8aebd544f174ae1c9bc230c71" + integrity sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw== + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chokidar@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +defu@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" + integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== + +diff@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae" + integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== + +dts-resolver@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/dts-resolver/-/dts-resolver-2.1.3.tgz#b930b38fcb2f3dab3b55cb4ac73658c9a5fc0a41" + integrity sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw== + +empathic@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/empathic/-/empathic-2.0.0.tgz#71d3c2b94fad49532ef98a6c34be0386659f6131" + integrity sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +esbuild@^0.25.0: + version "0.25.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" + integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.12" + "@esbuild/android-arm" "0.25.12" + "@esbuild/android-arm64" "0.25.12" + "@esbuild/android-x64" "0.25.12" + "@esbuild/darwin-arm64" "0.25.12" + "@esbuild/darwin-x64" "0.25.12" + "@esbuild/freebsd-arm64" "0.25.12" + "@esbuild/freebsd-x64" "0.25.12" + "@esbuild/linux-arm" "0.25.12" + "@esbuild/linux-arm64" "0.25.12" + "@esbuild/linux-ia32" "0.25.12" + "@esbuild/linux-loong64" "0.25.12" + "@esbuild/linux-mips64el" "0.25.12" + "@esbuild/linux-ppc64" "0.25.12" + "@esbuild/linux-riscv64" "0.25.12" + "@esbuild/linux-s390x" "0.25.12" + "@esbuild/linux-x64" "0.25.12" + "@esbuild/netbsd-arm64" "0.25.12" + "@esbuild/netbsd-x64" "0.25.12" + "@esbuild/openbsd-arm64" "0.25.12" + "@esbuild/openbsd-x64" "0.25.12" + "@esbuild/openharmony-arm64" "0.25.12" + "@esbuild/sunos-x64" "0.25.12" + "@esbuild/win32-arm64" "0.25.12" + "@esbuild/win32-ia32" "0.25.12" + "@esbuild/win32-x64" "0.25.12" + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-tsconfig@^4.10.1: + version "4.13.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz#fcdd991e6d22ab9a600f00e91c318707a5d9a0d7" + integrity sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ== + dependencies: + resolve-pkg-maps "^1.0.0" + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +jiti@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +magic-string@^0.30.17: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +quansync@^0.2.11: + version "0.2.11" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.11.tgz#f9c3adda2e1272e4f8cf3f1457b04cbdb4ee692a" + integrity sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA== + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +rolldown-plugin-dts@^0.15.8: + version "0.15.10" + resolved "https://registry.yarnpkg.com/rolldown-plugin-dts/-/rolldown-plugin-dts-0.15.10.tgz#c9ffeb2a70d27c3bca29797ef22a6e4f88d91d79" + integrity sha512-8cPVAVQUo9tYAoEpc3jFV9RxSil13hrRRg8cHC9gLXxRMNtWPc1LNMSDXzjyD+5Vny49sDZH77JlXp/vlc4I3g== + dependencies: + "@babel/generator" "^7.28.3" + "@babel/parser" "^7.28.3" + "@babel/types" "^7.28.2" + ast-kit "^2.1.2" + birpc "^2.5.0" + debug "^4.4.1" + dts-resolver "^2.1.2" + get-tsconfig "^4.10.1" + +rolldown@latest: + version "1.0.0-beta.51" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-beta.51.tgz#e4fedd0450d1d611f406f9336fee30180751e5f1" + integrity sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg== + dependencies: + "@oxc-project/types" "=0.98.0" + "@rolldown/pluginutils" "1.0.0-beta.51" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.0.0-beta.51" + "@rolldown/binding-darwin-arm64" "1.0.0-beta.51" + "@rolldown/binding-darwin-x64" "1.0.0-beta.51" + "@rolldown/binding-freebsd-x64" "1.0.0-beta.51" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-beta.51" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-beta.51" + "@rolldown/binding-linux-arm64-musl" "1.0.0-beta.51" + "@rolldown/binding-linux-x64-gnu" "1.0.0-beta.51" + "@rolldown/binding-linux-x64-musl" "1.0.0-beta.51" + "@rolldown/binding-openharmony-arm64" "1.0.0-beta.51" + "@rolldown/binding-wasm32-wasi" "1.0.0-beta.51" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-beta.51" + "@rolldown/binding-win32-ia32-msvc" "1.0.0-beta.51" + "@rolldown/binding-win32-x64-msvc" "1.0.0-beta.51" + +rollup@^4.43.0: + version "4.53.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" + integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.53.3" + "@rollup/rollup-android-arm64" "4.53.3" + "@rollup/rollup-darwin-arm64" "4.53.3" + "@rollup/rollup-darwin-x64" "4.53.3" + "@rollup/rollup-freebsd-arm64" "4.53.3" + "@rollup/rollup-freebsd-x64" "4.53.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" + "@rollup/rollup-linux-arm-musleabihf" "4.53.3" + "@rollup/rollup-linux-arm64-gnu" "4.53.3" + "@rollup/rollup-linux-arm64-musl" "4.53.3" + "@rollup/rollup-linux-loong64-gnu" "4.53.3" + "@rollup/rollup-linux-ppc64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-musl" "4.53.3" + "@rollup/rollup-linux-s390x-gnu" "4.53.3" + "@rollup/rollup-linux-x64-gnu" "4.53.3" + "@rollup/rollup-linux-x64-musl" "4.53.3" + "@rollup/rollup-openharmony-arm64" "4.53.3" + "@rollup/rollup-win32-arm64-msvc" "4.53.3" + "@rollup/rollup-win32-ia32-msvc" "4.53.3" + "@rollup/rollup-win32-x64-gnu" "4.53.3" + "@rollup/rollup-win32-x64-msvc" "4.53.3" + fsevents "~2.3.2" + +semver@^7.7.2: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyexec@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +tsdown@^0.14.1: + version "0.14.2" + resolved "https://registry.yarnpkg.com/tsdown/-/tsdown-0.14.2.tgz#8401fd6032b1ea200e2d5e666648769e0cea98c2" + integrity sha512-6ThtxVZoTlR5YJov5rYvH8N1+/S/rD/pGfehdCLGznGgbxz+73EASV1tsIIZkLw2n+SXcERqHhcB/OkyxdKv3A== + dependencies: + ansis "^4.1.0" + cac "^6.7.14" + chokidar "^4.0.3" + debug "^4.4.1" + diff "^8.0.2" + empathic "^2.0.0" + hookable "^5.5.3" + rolldown latest + rolldown-plugin-dts "^0.15.8" + semver "^7.7.2" + tinyexec "^1.0.1" + tinyglobby "^0.2.14" + tree-kill "^1.2.2" + unconfig "^7.3.3" + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +typescript@^5.9.2: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +unconfig-core@7.4.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/unconfig-core/-/unconfig-core-7.4.1.tgz#57cbea6ea1b066f83450d794373544b178cfe8fd" + integrity sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA== + dependencies: + "@quansync/fs" "^0.1.5" + quansync "^0.2.11" + +unconfig@^7.3.3: + version "7.4.1" + resolved "https://registry.yarnpkg.com/unconfig/-/unconfig-7.4.1.tgz#7e42c5e9cdfef90bad84d80cb498bbefef10cfdc" + integrity sha512-uyQ7LElcGizrOGZyIq9KU+xkuEjcRf9IpmDTkCSYv5mEeZzrXSj6rb51C0L+WTedsmAoVxW9WKrLWhSwebIM9Q== + dependencies: + "@quansync/fs" "^0.1.5" + defu "^6.1.4" + jiti "^2.6.1" + quansync "^0.2.11" + unconfig-core "7.4.1" + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.2.4" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.4.tgz#a3a09c7e25487612ecc1119c7d412c73da35bd4e" + integrity sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@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 "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" diff --git a/packages/loro-websocket/src/server/simple-server.ts b/packages/loro-websocket/src/server/simple-server.ts index ebdf7dd..767142a 100644 --- a/packages/loro-websocket/src/server/simple-server.ts +++ b/packages/loro-websocket/src/server/simple-server.ts @@ -1,6 +1,7 @@ import { WebSocketServer, WebSocket } from "ws"; import { randomBytes } from "node:crypto"; import type { RawData } from "ws"; +import type { IncomingMessage } from "http"; // no direct CRDT imports here; handled by CrdtDoc implementations import { encode, @@ -47,6 +48,13 @@ export interface SimpleServerConfig { crdtType: CrdtType, auth: Uint8Array ) => Promise; + /** + * Optional handshake auth: called during WS HTTP upgrade. + * Return true to accept, false to reject. + */ + handshakeAuth?: ( + req: IncomingMessage + ) => boolean | Promise; } interface RoomDocument { @@ -86,12 +94,28 @@ export class SimpleServer { start(): Promise { return new Promise(resolve => { - const options: { port: number; host?: string } = { + const options: { port: number; host?: string; verifyClient?: any } = { port: this.config.port, }; if (this.config.host) { options.host = this.config.host; } + if (this.config.handshakeAuth) { + options.verifyClient = ( + info: { origin: string; secure: boolean; req: IncomingMessage }, + cb: (res: boolean, code?: number, message?: string) => void + ) => { + Promise.resolve(this.config.handshakeAuth!(info.req)) + .then(allowed => { + if (allowed) cb(true); + else cb(false, 401, "Unauthorized"); + }) + .catch(err => { + console.error("Handshake auth error", err); + cb(false, 500, "Internal Server Error"); + }); + }; + } this.wss = new WebSocketServer(options); this.wss.on("connection", ws => { diff --git a/packages/loro-websocket/tests/handshake-auth.test.ts b/packages/loro-websocket/tests/handshake-auth.test.ts new file mode 100644 index 0000000..910857d --- /dev/null +++ b/packages/loro-websocket/tests/handshake-auth.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { WebSocket } from "ws"; +import getPort from "get-port"; +import { SimpleServer } from "../src/server/simple-server"; + +// Make WebSocket available globally for the client +Object.defineProperty(globalThis, "WebSocket", { + value: WebSocket, + configurable: true, + writable: true, +}); + +describe("Handshake Auth", () => { + let server: SimpleServer; + let port: number; + + beforeAll(async () => { + port = await getPort(); + server = new SimpleServer({ + port, + handshakeAuth: req => { + const cookie = req.headers.cookie; + return cookie === "session=valid"; + }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }, 10000); + + it("should accept connection with valid cookie", async () => { + const ws = new WebSocket(`ws://localhost:${port}`, { + headers: { + Cookie: "session=valid", + }, + }); + + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = err => reject(err); + }); + ws.close(); + }); + + it("should reject connection with invalid cookie", async () => { + const ws = new WebSocket(`ws://localhost:${port}`, { + headers: { + Cookie: "session=invalid", + }, + }); + + await new Promise((resolve, reject) => { + ws.onopen = () => reject(new Error("Should have failed")); + ws.onerror = err => { + resolve(); + }; + }); + }); + + it("should reject connection with missing cookie", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + + await new Promise((resolve, reject) => { + ws.onopen = () => reject(new Error("Should have failed")); + ws.onerror = () => resolve(); + }); + }); +}); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d418930..6f25d15 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -314,6 +314,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -390,6 +400,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1003,6 +1022,7 @@ name = "loro-websocket-server" version = "0.1.0" dependencies = [ "clap", + "cookie", "futures-util", "loro", "loro-protocol", @@ -1124,6 +1144,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1263,6 +1289,12 @@ dependencies = [ "serde", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1723,6 +1755,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.47.1" diff --git a/rust/loro-websocket-server/Cargo.toml b/rust/loro-websocket-server/Cargo.toml index 297c313..6f60c92 100644 --- a/rust/loro-websocket-server/Cargo.toml +++ b/rust/loro-websocket-server/Cargo.toml @@ -20,6 +20,7 @@ tokio-tungstenite = "0.27" futures-util = { version = "0.3", default-features = false, features = ["sink"] } loro = "1" tracing = "0.1" +cookie = "0.18.1" [dev-dependencies] loro-websocket-client = { version = "0.1.0", path = "../loro-websocket-client" } diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index a56aff7..f852784 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -108,7 +108,7 @@ type AuthFuture = Pin, String>> + Send + 'static>>; type AuthFn = Arc) -> AuthFuture + Send + Sync>; -type HandshakeAuthFn = dyn Fn(&str, Option<&str>) -> bool + Send + Sync; +type HandshakeAuthFn = dyn Fn(&str, Option<&str>, &HashMap) -> bool + Send + Sync; #[derive(Clone)] pub struct ServerConfig { @@ -122,6 +122,7 @@ pub struct ServerConfig { /// Parameters: /// - `workspace_id`: extracted from request path `/{workspace}` (empty if missing) /// - `token`: `token` query parameter if present + /// - `cookies`: parsed cookies from `Cookie` header /// /// Return true to accept, false to reject with 401. pub handshake_auth: Option>, @@ -925,7 +926,19 @@ where None }); - let allowed = (check)(workspace_id, token); + // Parse cookies + let mut cookies = HashMap::new(); + if let Some(header) = req.headers().get("Cookie") { + if let Ok(s) = header.to_str() { + for cookie in cookie::Cookie::split_parse(s) { + if let Ok(c) = cookie { + cookies.insert(c.name().to_string(), c.value().to_string()); + } + } + } + } + + let allowed = (check)(workspace_id, token, &cookies); if !allowed { warn!(workspace=%workspace_id, token=?token, "handshake auth denied"); // Build a 401 Unauthorized response diff --git a/rust/loro-websocket-server/tests/e2e.rs b/rust/loro-websocket-server/tests/e2e.rs index d230f60..4d0b277 100644 --- a/rust/loro-websocket-server/tests/e2e.rs +++ b/rust/loro-websocket-server/tests/e2e.rs @@ -14,7 +14,7 @@ async fn e2e_sync_two_clients_docupdate_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -65,7 +65,7 @@ async fn workspaces_are_isolated() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -104,7 +104,7 @@ async fn e2e_sync_two_clients_loro_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -154,7 +154,7 @@ async fn e2e_sync_two_clients_elo_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs index 7e2d3b8..7968208 100644 --- a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs +++ b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs @@ -11,7 +11,7 @@ async fn elo_accepts_join_and_broadcasts_updates() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs index a9ef6a4..ba28c0f 100644 --- a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs +++ b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs @@ -19,7 +19,7 @@ async fn elo_fragment_reassembly_broadcasts_original_frames() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_auth.rs b/rust/loro-websocket-server/tests/handshake_auth.rs index 6206180..20ce01b 100644 --- a/rust/loro-websocket-server/tests/handshake_auth.rs +++ b/rust/loro-websocket-server/tests/handshake_auth.rs @@ -9,7 +9,7 @@ async fn handshake_rejects_invalid_token_with_401() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_cookies.rs b/rust/loro-websocket-server/tests/handshake_cookies.rs new file mode 100644 index 0000000..b520a67 --- /dev/null +++ b/rust/loro-websocket-server/tests/handshake_cookies.rs @@ -0,0 +1,66 @@ +use loro_websocket_server as server; +use std::sync::Arc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; + +#[tokio::test(flavor = "current_thread")] +async fn handshake_auth_can_read_cookies() { + // Start server requiring cookie "session=valid" + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_task = tokio::spawn(async move { + let cfg: server::ServerConfig<()> = server::ServerConfig { + handshake_auth: Some(Arc::new(|_ws, _token, cookies| { + cookies.get("session").map(|v| v.as_str()) == Some("valid") + })), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + }); + + let url = format!("ws://{}/ws1", addr); + + // 1. Test valid cookie + { + let mut req = url.clone().into_client_request().unwrap(); + req.headers_mut().insert( + "Cookie", + HeaderValue::from_static("session=valid; other=stuff"), + ); + match tokio_tungstenite::connect_async(req).await { + Ok(_) => {} // success + Err(e) => panic!("valid cookie should be accepted: {}", e), + } + } + + // 2. Test missing cookie + { + let req = url.clone().into_client_request().unwrap(); + // no cookie header + match tokio_tungstenite::connect_async(req).await { + Ok(_) => panic!("missing cookie should be rejected"), + Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { + assert_eq!(resp.status(), 401); + } + Err(e) => panic!("unexpected error for missing cookie: {}", e), + } + } + + // 3. Test invalid cookie value + { + let mut req = url.clone().into_client_request().unwrap(); + req.headers_mut() + .insert("Cookie", HeaderValue::from_static("session=invalid")); + match tokio_tungstenite::connect_async(req).await { + Ok(_) => panic!("invalid cookie should be rejected"), + Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { + assert_eq!(resp.status(), 401); + } + Err(e) => panic!("unexpected error for invalid cookie: {}", e), + } + } + + server_task.abort(); +} diff --git a/rust/loro-websocket-server/tests/join_denied.rs b/rust/loro-websocket-server/tests/join_denied.rs index 34450a3..5d3b9ef 100644 --- a/rust/loro-websocket-server/tests/join_denied.rs +++ b/rust/loro-websocket-server/tests/join_denied.rs @@ -13,7 +13,7 @@ async fn join_denied_returns_error() { let cfg: server::ServerConfig<()> = server::ServerConfig { authenticate: Some(Arc::new(|_room, _crdt, _auth| Box::pin(async { Ok(None) }))), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/join_snapshot_load.rs b/rust/loro-websocket-server/tests/join_snapshot_load.rs index 7a2ac79..37caa4e 100644 --- a/rust/loro-websocket-server/tests/join_snapshot_load.rs +++ b/rust/loro-websocket-server/tests/join_snapshot_load.rs @@ -24,7 +24,7 @@ async fn join_sends_snapshot_from_loader() { }) }) })), - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/readonly_receive.rs b/rust/loro-websocket-server/tests/readonly_receive.rs index 62106a2..4b5ee91 100644 --- a/rust/loro-websocket-server/tests/readonly_receive.rs +++ b/rust/loro-websocket-server/tests/readonly_receive.rs @@ -24,7 +24,7 @@ async fn readonly_receives_updates_writer_sends() { }) })), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/reject_update_without_join.rs b/rust/loro-websocket-server/tests/reject_update_without_join.rs index 852322f..50fac08 100644 --- a/rust/loro-websocket-server/tests/reject_update_without_join.rs +++ b/rust/loro-websocket-server/tests/reject_update_without_join.rs @@ -9,7 +9,7 @@ async fn reject_update_without_join() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) From 23474f7d411138531d7924b10c22294559fe4386 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Mon, 15 Dec 2025 14:12:06 +0100 Subject: [PATCH 02/15] Removing the yarn.lock file --- packages/loro-protocol/yarn.lock | 1000 ------------------------------ 1 file changed, 1000 deletions(-) delete mode 100644 packages/loro-protocol/yarn.lock diff --git a/packages/loro-protocol/yarn.lock b/packages/loro-protocol/yarn.lock deleted file mode 100644 index 9d81878..0000000 --- a/packages/loro-protocol/yarn.lock +++ /dev/null @@ -1,1000 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/generator@^7.28.3": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" - integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== - dependencies: - "@babel/parser" "^7.28.5" - "@babel/types" "^7.28.5" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" - jsesc "^3.0.2" - -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== - -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - -"@babel/parser@^7.28.3", "@babel/parser@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" - integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== - dependencies: - "@babel/types" "^7.28.5" - -"@babel/types@^7.28.2", "@babel/types@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - -"@emnapi/core@^1.5.0": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4" - integrity sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg== - dependencies: - "@emnapi/wasi-threads" "1.1.0" - tslib "^2.4.0" - -"@emnapi/runtime@^1.5.0": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" - integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== - dependencies: - tslib "^2.4.0" - -"@esbuild/aix-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" - integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== - -"@esbuild/android-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" - integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== - -"@esbuild/android-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" - integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== - -"@esbuild/android-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" - integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== - -"@esbuild/darwin-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" - integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== - -"@esbuild/darwin-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" - integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== - -"@esbuild/freebsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" - integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== - -"@esbuild/freebsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" - integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== - -"@esbuild/linux-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" - integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== - -"@esbuild/linux-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" - integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== - -"@esbuild/linux-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" - integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== - -"@esbuild/linux-loong64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" - integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== - -"@esbuild/linux-mips64el@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" - integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== - -"@esbuild/linux-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" - integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== - -"@esbuild/linux-riscv64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" - integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== - -"@esbuild/linux-s390x@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" - integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== - -"@esbuild/linux-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" - integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== - -"@esbuild/netbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" - integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== - -"@esbuild/netbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" - integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== - -"@esbuild/openbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" - integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== - -"@esbuild/openbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" - integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== - -"@esbuild/openharmony-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" - integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== - -"@esbuild/sunos-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" - integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== - -"@esbuild/win32-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" - integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== - -"@esbuild/win32-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" - integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== - -"@esbuild/win32-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" - integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== - -"@jridgewell/gen-mapping@^0.3.12": - version "0.3.13" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" - integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" - integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== - -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== - -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@napi-rs/wasm-runtime@^1.0.7": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz#dcfea99a75f06209a235f3d941e3460a51e9b14c" - integrity sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw== - dependencies: - "@emnapi/core" "^1.5.0" - "@emnapi/runtime" "^1.5.0" - "@tybys/wasm-util" "^0.10.1" - -"@oxc-project/types@=0.98.0": - version "0.98.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.98.0.tgz#2deb27674e3ac0f76add4fd40a4887fd91cdf118" - integrity sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw== - -"@quansync/fs@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@quansync/fs/-/fs-0.1.5.tgz#8d89bc7add93f9b77e053ff8293d5ca82199fb1f" - integrity sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA== - dependencies: - quansync "^0.2.11" - -"@rolldown/binding-android-arm64@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.51.tgz#9cbe285955665eba6f29578b230352f1f22c3991" - integrity sha512-Ctn8FUXKWWQI9pWC61P1yumS9WjQtelNS9riHwV7oCkknPGaAry4o7eFx2KgoLMnI2BgFJYpW7Im8/zX3BuONg== - -"@rolldown/binding-darwin-arm64@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.51.tgz#b15f34e9082e08b14f9df12a5b5e9cd8958bd53b" - integrity sha512-EL1aRW2Oq15ShUEkBPsDtLMO8GTqfb/ktM/dFaVzXKQiEE96Ss6nexMgfgQrg8dGnNpndFyffVDb5IdSibsu1g== - -"@rolldown/binding-darwin-x64@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.51.tgz#27a8d3073c53c35d63077f1cf496db29bb9d2ea2" - integrity sha512-uGtYKlFen9pMIPvkHPWZVDtmYhMQi5g5Ddsndg1gf3atScKYKYgs5aDP4DhHeTwGXQglhfBG7lEaOIZ4UAIWww== - -"@rolldown/binding-freebsd-x64@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.51.tgz#17f2836fb45e0039901b0f8012637ef64ca1e95c" - integrity sha512-JRoVTQtHYbZj1P07JLiuTuXjiBtIa7ag7/qgKA6CIIXnAcdl4LrOf7nfDuHPJcuRKaP5dzecMgY99itvWfmUFQ== - -"@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.51.tgz#14f1310c66c1d1ddbf05c641605817ce05eeeb65" - integrity sha512-BKATVnpPZ0TYBW9XfDwyd4kPGgvf964HiotIwUgpMrFOFYWqpZ+9ONNzMV4UFAYC7Hb5C2qgYQk/qj2OnAd4RQ== - -"@rolldown/binding-linux-arm64-gnu@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.51.tgz#eb53a6d73cda90e390afb99b80032bf166ef4d22" - integrity sha512-xLd7da5jkfbVsBCm1buIRdWtuXY8+hU3+6ESXY/Tk5X5DPHaifrUblhYDgmA34dQt6WyNC2kfXGgrduPEvDI6Q== - -"@rolldown/binding-linux-arm64-musl@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.51.tgz#528cd4aba1ff8032596a5ef73ccc634d53434714" - integrity sha512-EQFXTgHxxTzv3t5EmjUP/DfxzFYx9sMndfLsYaAY4DWF6KsK1fXGYsiupif6qPTViPC9eVmRm78q0pZU/kuIPg== - -"@rolldown/binding-linux-x64-gnu@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.51.tgz#df7987621f95e30d449e7e3293d14c1a5d5ef33f" - integrity sha512-p5P6Xpa68w3yFaAdSzIZJbj+AfuDnMDqNSeglBXM7UlJT14Q4zwK+rV+8Mhp9MiUb4XFISZtbI/seBprhkQbiQ== - -"@rolldown/binding-linux-x64-musl@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.51.tgz#da5e7a5980c24a234076330722600874e7c85f48" - integrity sha512-sNVVyLa8HB8wkFipdfz1s6i0YWinwpbMWk5hO5S+XAYH2UH67YzUT13gs6wZTKg2x/3gtgXzYnHyF5wMIqoDAw== - -"@rolldown/binding-openharmony-arm64@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.51.tgz#b200f52442a01beeba8b9b7f79ce0197b58e7ddc" - integrity sha512-e/JMTz9Q8+T3g/deEi8DK44sFWZWGKr9AOCW5e8C8SCVWzAXqYXAG7FXBWBNzWEZK0Rcwo9TQHTQ9Q0gXgdCaA== - -"@rolldown/binding-wasm32-wasi@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.51.tgz#f52796b9850aee7010144efe11b6fb3a6755d3c9" - integrity sha512-We3LWqSu6J9s5Y0MK+N7fUiiu37aBGPG3Pc347EoaROuAwkCS2u9xJ5dpIyLW4B49CIbS3KaPmn4kTgPb3EyPw== - dependencies: - "@napi-rs/wasm-runtime" "^1.0.7" - -"@rolldown/binding-win32-arm64-msvc@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.51.tgz#aa5ae9a6bf19433c442c9d7e1eaadd94ced6015f" - integrity sha512-fj56buHRuMM+r/cb6ZYfNjNvO/0xeFybI6cTkTROJatdP4fvmQ1NS8D/Lm10FCSDEOkqIz8hK3TGpbAThbPHsA== - -"@rolldown/binding-win32-ia32-msvc@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.51.tgz#76c2f69bd11b6f06fc490719753236b26b2ec3a9" - integrity sha512-fkqEqaeEx8AySXiDm54b/RdINb3C0VovzJA3osMhZsbn6FoD73H0AOIiaVAtGr6x63hefruVKTX8irAm4Jkt2w== - -"@rolldown/binding-win32-x64-msvc@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.51.tgz#2b3cd4f7fc6bea58fef84ae72dd3bd0eee2dac2b" - integrity sha512-CWuLG/HMtrVcjKGa0C4GnuxONrku89g0+CsH8nT0SNhOtREXuzwgjIXNJImpE/A/DMf9JF+1Xkrq/YRr+F/rCg== - -"@rolldown/pluginutils@1.0.0-beta.51": - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.51.tgz#dd482c825c7af3c656c01cf1cbd6e0e3cb478ed9" - integrity sha512-51/8cNXMrqWqX3o8DZidhwz1uYq0BhHDDSfVygAND1Skx5s1TDw3APSSxCMcFFedwgqGcx34gRouwY+m404BBQ== - -"@rollup/rollup-android-arm-eabi@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" - integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== - -"@rollup/rollup-android-arm64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" - integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== - -"@rollup/rollup-darwin-arm64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" - integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== - -"@rollup/rollup-darwin-x64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" - integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== - -"@rollup/rollup-freebsd-arm64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" - integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== - -"@rollup/rollup-freebsd-x64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" - integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" - integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== - -"@rollup/rollup-linux-arm-musleabihf@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" - integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== - -"@rollup/rollup-linux-arm64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" - integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== - -"@rollup/rollup-linux-arm64-musl@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" - integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== - -"@rollup/rollup-linux-loong64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" - integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== - -"@rollup/rollup-linux-ppc64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" - integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== - -"@rollup/rollup-linux-riscv64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" - integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== - -"@rollup/rollup-linux-riscv64-musl@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" - integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== - -"@rollup/rollup-linux-s390x-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" - integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== - -"@rollup/rollup-linux-x64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" - integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== - -"@rollup/rollup-linux-x64-musl@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" - integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== - -"@rollup/rollup-openharmony-arm64@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" - integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== - -"@rollup/rollup-win32-arm64-msvc@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" - integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== - -"@rollup/rollup-win32-ia32-msvc@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" - integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== - -"@rollup/rollup-win32-x64-gnu@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" - integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== - -"@rollup/rollup-win32-x64-msvc@4.53.3": - version "4.53.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" - integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== - -"@tybys/wasm-util@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== - dependencies: - tslib "^2.4.0" - -"@types/chai@^5.2.2": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" - integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== - dependencies: - "@types/deep-eql" "*" - assertion-error "^2.0.1" - -"@types/deep-eql@*": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" - integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== - -"@types/estree@1.0.8", "@types/estree@^1.0.0": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== - -"@vitest/expect@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" - integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== - dependencies: - "@types/chai" "^5.2.2" - "@vitest/spy" "3.2.4" - "@vitest/utils" "3.2.4" - chai "^5.2.0" - tinyrainbow "^2.0.0" - -"@vitest/mocker@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" - integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== - dependencies: - "@vitest/spy" "3.2.4" - estree-walker "^3.0.3" - magic-string "^0.30.17" - -"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" - integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== - dependencies: - tinyrainbow "^2.0.0" - -"@vitest/runner@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" - integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== - dependencies: - "@vitest/utils" "3.2.4" - pathe "^2.0.3" - strip-literal "^3.0.0" - -"@vitest/snapshot@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" - integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== - dependencies: - "@vitest/pretty-format" "3.2.4" - magic-string "^0.30.17" - pathe "^2.0.3" - -"@vitest/spy@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" - integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== - dependencies: - tinyspy "^4.0.3" - -"@vitest/utils@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" - integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== - dependencies: - "@vitest/pretty-format" "3.2.4" - loupe "^3.1.4" - tinyrainbow "^2.0.0" - -ansis@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.2.0.tgz#2e6e61c46b11726ac67f78785385618b9e658780" - integrity sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig== - -assertion-error@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" - integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== - -ast-kit@^2.1.2: - version "2.2.0" - resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-2.2.0.tgz#6d9a298acefef5bdfc5a0fa51d94d1334ef2e671" - integrity sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw== - dependencies: - "@babel/parser" "^7.28.5" - pathe "^2.0.3" - -birpc@^2.5.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.8.0.tgz#064c90bda7912ef8aebd544f174ae1c9bc230c71" - integrity sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw== - -cac@^6.7.14: - version "6.7.14" - resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" - integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== - -chai@^5.2.0: - version "5.3.3" - resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" - integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== - dependencies: - assertion-error "^2.0.1" - check-error "^2.1.1" - deep-eql "^5.0.1" - loupe "^3.1.0" - pathval "^2.0.0" - -check-error@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" - integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== - -chokidar@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" - integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== - dependencies: - readdirp "^4.0.1" - -debug@^4.4.1: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -deep-eql@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" - integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== - -defu@^6.1.4: - version "6.1.4" - resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" - integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== - -diff@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae" - integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== - -dts-resolver@^2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/dts-resolver/-/dts-resolver-2.1.3.tgz#b930b38fcb2f3dab3b55cb4ac73658c9a5fc0a41" - integrity sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw== - -empathic@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/empathic/-/empathic-2.0.0.tgz#71d3c2b94fad49532ef98a6c34be0386659f6131" - integrity sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA== - -es-module-lexer@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" - integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== - -esbuild@^0.25.0: - version "0.25.12" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" - integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== - optionalDependencies: - "@esbuild/aix-ppc64" "0.25.12" - "@esbuild/android-arm" "0.25.12" - "@esbuild/android-arm64" "0.25.12" - "@esbuild/android-x64" "0.25.12" - "@esbuild/darwin-arm64" "0.25.12" - "@esbuild/darwin-x64" "0.25.12" - "@esbuild/freebsd-arm64" "0.25.12" - "@esbuild/freebsd-x64" "0.25.12" - "@esbuild/linux-arm" "0.25.12" - "@esbuild/linux-arm64" "0.25.12" - "@esbuild/linux-ia32" "0.25.12" - "@esbuild/linux-loong64" "0.25.12" - "@esbuild/linux-mips64el" "0.25.12" - "@esbuild/linux-ppc64" "0.25.12" - "@esbuild/linux-riscv64" "0.25.12" - "@esbuild/linux-s390x" "0.25.12" - "@esbuild/linux-x64" "0.25.12" - "@esbuild/netbsd-arm64" "0.25.12" - "@esbuild/netbsd-x64" "0.25.12" - "@esbuild/openbsd-arm64" "0.25.12" - "@esbuild/openbsd-x64" "0.25.12" - "@esbuild/openharmony-arm64" "0.25.12" - "@esbuild/sunos-x64" "0.25.12" - "@esbuild/win32-arm64" "0.25.12" - "@esbuild/win32-ia32" "0.25.12" - "@esbuild/win32-x64" "0.25.12" - -estree-walker@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" - integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== - dependencies: - "@types/estree" "^1.0.0" - -expect-type@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" - integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== - -fdir@^6.5.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" - integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== - -fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -get-tsconfig@^4.10.1: - version "4.13.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz#fcdd991e6d22ab9a600f00e91c318707a5d9a0d7" - integrity sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ== - dependencies: - resolve-pkg-maps "^1.0.0" - -hookable@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" - integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== - -jiti@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" - integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== - -js-tokens@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" - integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== - -jsesc@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" - integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== - -loupe@^3.1.0, loupe@^3.1.4: - version "3.2.1" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" - integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== - -magic-string@^0.30.17: - version "0.30.21" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" - integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.5" - -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== - -pathe@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" - integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== - -pathval@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" - integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== - -picocolors@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - -picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== - -postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== - dependencies: - nanoid "^3.3.11" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -quansync@^0.2.11: - version "0.2.11" - resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.11.tgz#f9c3adda2e1272e4f8cf3f1457b04cbdb4ee692a" - integrity sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA== - -readdirp@^4.0.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" - integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== - -resolve-pkg-maps@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" - integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== - -rolldown-plugin-dts@^0.15.8: - version "0.15.10" - resolved "https://registry.yarnpkg.com/rolldown-plugin-dts/-/rolldown-plugin-dts-0.15.10.tgz#c9ffeb2a70d27c3bca29797ef22a6e4f88d91d79" - integrity sha512-8cPVAVQUo9tYAoEpc3jFV9RxSil13hrRRg8cHC9gLXxRMNtWPc1LNMSDXzjyD+5Vny49sDZH77JlXp/vlc4I3g== - dependencies: - "@babel/generator" "^7.28.3" - "@babel/parser" "^7.28.3" - "@babel/types" "^7.28.2" - ast-kit "^2.1.2" - birpc "^2.5.0" - debug "^4.4.1" - dts-resolver "^2.1.2" - get-tsconfig "^4.10.1" - -rolldown@latest: - version "1.0.0-beta.51" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-beta.51.tgz#e4fedd0450d1d611f406f9336fee30180751e5f1" - integrity sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg== - dependencies: - "@oxc-project/types" "=0.98.0" - "@rolldown/pluginutils" "1.0.0-beta.51" - optionalDependencies: - "@rolldown/binding-android-arm64" "1.0.0-beta.51" - "@rolldown/binding-darwin-arm64" "1.0.0-beta.51" - "@rolldown/binding-darwin-x64" "1.0.0-beta.51" - "@rolldown/binding-freebsd-x64" "1.0.0-beta.51" - "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-beta.51" - "@rolldown/binding-linux-arm64-gnu" "1.0.0-beta.51" - "@rolldown/binding-linux-arm64-musl" "1.0.0-beta.51" - "@rolldown/binding-linux-x64-gnu" "1.0.0-beta.51" - "@rolldown/binding-linux-x64-musl" "1.0.0-beta.51" - "@rolldown/binding-openharmony-arm64" "1.0.0-beta.51" - "@rolldown/binding-wasm32-wasi" "1.0.0-beta.51" - "@rolldown/binding-win32-arm64-msvc" "1.0.0-beta.51" - "@rolldown/binding-win32-ia32-msvc" "1.0.0-beta.51" - "@rolldown/binding-win32-x64-msvc" "1.0.0-beta.51" - -rollup@^4.43.0: - version "4.53.3" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" - integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== - dependencies: - "@types/estree" "1.0.8" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.53.3" - "@rollup/rollup-android-arm64" "4.53.3" - "@rollup/rollup-darwin-arm64" "4.53.3" - "@rollup/rollup-darwin-x64" "4.53.3" - "@rollup/rollup-freebsd-arm64" "4.53.3" - "@rollup/rollup-freebsd-x64" "4.53.3" - "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" - "@rollup/rollup-linux-arm-musleabihf" "4.53.3" - "@rollup/rollup-linux-arm64-gnu" "4.53.3" - "@rollup/rollup-linux-arm64-musl" "4.53.3" - "@rollup/rollup-linux-loong64-gnu" "4.53.3" - "@rollup/rollup-linux-ppc64-gnu" "4.53.3" - "@rollup/rollup-linux-riscv64-gnu" "4.53.3" - "@rollup/rollup-linux-riscv64-musl" "4.53.3" - "@rollup/rollup-linux-s390x-gnu" "4.53.3" - "@rollup/rollup-linux-x64-gnu" "4.53.3" - "@rollup/rollup-linux-x64-musl" "4.53.3" - "@rollup/rollup-openharmony-arm64" "4.53.3" - "@rollup/rollup-win32-arm64-msvc" "4.53.3" - "@rollup/rollup-win32-ia32-msvc" "4.53.3" - "@rollup/rollup-win32-x64-gnu" "4.53.3" - "@rollup/rollup-win32-x64-msvc" "4.53.3" - fsevents "~2.3.2" - -semver@^7.7.2: - version "7.7.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== - -siginfo@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" - integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== - -source-map-js@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== - -stackback@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" - integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== - -std-env@^3.9.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" - integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== - -strip-literal@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" - integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== - dependencies: - js-tokens "^9.0.1" - -tinybench@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" - integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== - -tinyexec@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" - integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== - -tinyexec@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" - integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== - -tinyglobby@^0.2.14, tinyglobby@^0.2.15: - version "0.2.15" - resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" - integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== - dependencies: - fdir "^6.5.0" - picomatch "^4.0.3" - -tinypool@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" - integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== - -tinyrainbow@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" - integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== - -tinyspy@^4.0.3: - version "4.0.4" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" - integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== - -tree-kill@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" - integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== - -tsdown@^0.14.1: - version "0.14.2" - resolved "https://registry.yarnpkg.com/tsdown/-/tsdown-0.14.2.tgz#8401fd6032b1ea200e2d5e666648769e0cea98c2" - integrity sha512-6ThtxVZoTlR5YJov5rYvH8N1+/S/rD/pGfehdCLGznGgbxz+73EASV1tsIIZkLw2n+SXcERqHhcB/OkyxdKv3A== - dependencies: - ansis "^4.1.0" - cac "^6.7.14" - chokidar "^4.0.3" - debug "^4.4.1" - diff "^8.0.2" - empathic "^2.0.0" - hookable "^5.5.3" - rolldown latest - rolldown-plugin-dts "^0.15.8" - semver "^7.7.2" - tinyexec "^1.0.1" - tinyglobby "^0.2.14" - tree-kill "^1.2.2" - unconfig "^7.3.3" - -tslib@^2.4.0: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - -typescript@^5.9.2: - version "5.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - -unconfig-core@7.4.1: - version "7.4.1" - resolved "https://registry.yarnpkg.com/unconfig-core/-/unconfig-core-7.4.1.tgz#57cbea6ea1b066f83450d794373544b178cfe8fd" - integrity sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA== - dependencies: - "@quansync/fs" "^0.1.5" - quansync "^0.2.11" - -unconfig@^7.3.3: - version "7.4.1" - resolved "https://registry.yarnpkg.com/unconfig/-/unconfig-7.4.1.tgz#7e42c5e9cdfef90bad84d80cb498bbefef10cfdc" - integrity sha512-uyQ7LElcGizrOGZyIq9KU+xkuEjcRf9IpmDTkCSYv5mEeZzrXSj6rb51C0L+WTedsmAoVxW9WKrLWhSwebIM9Q== - dependencies: - "@quansync/fs" "^0.1.5" - defu "^6.1.4" - jiti "^2.6.1" - quansync "^0.2.11" - unconfig-core "7.4.1" - -vite-node@3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" - integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== - dependencies: - cac "^6.7.14" - debug "^4.4.1" - es-module-lexer "^1.7.0" - pathe "^2.0.3" - vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" - -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": - version "7.2.4" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.4.tgz#a3a09c7e25487612ecc1119c7d412c73da35bd4e" - integrity sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w== - dependencies: - esbuild "^0.25.0" - fdir "^6.5.0" - picomatch "^4.0.3" - postcss "^8.5.6" - rollup "^4.43.0" - tinyglobby "^0.2.15" - optionalDependencies: - fsevents "~2.3.3" - -vitest@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" - integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== - dependencies: - "@types/chai" "^5.2.2" - "@vitest/expect" "3.2.4" - "@vitest/mocker" "3.2.4" - "@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 "^2.0.3" - picomatch "^4.0.2" - std-env "^3.9.0" - tinybench "^2.9.0" - tinyexec "^0.3.2" - tinyglobby "^0.2.14" - tinypool "^1.1.1" - tinyrainbow "^2.0.0" - vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node "3.2.4" - why-is-node-running "^2.3.0" - -why-is-node-running@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" - integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== - dependencies: - siginfo "^2.0.0" - stackback "0.0.2" From c9f6b13b97165ac4b95cee8b4055d91cf09a92d2 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Mon, 15 Dec 2025 20:42:26 +0100 Subject: [PATCH 03/15] Include request struct in handshake_auth instead of cookies --- rust/loro-websocket-server/src/lib.rs | 18 +++--------------- rust/loro-websocket-server/tests/e2e.rs | 8 ++++---- .../tests/elo_accept_broadcast.rs | 2 +- .../tests/elo_fragment_reassembly.rs | 2 +- .../tests/handshake_auth.rs | 2 +- .../tests/handshake_cookies.rs | 15 +++++++++++++-- .../loro-websocket-server/tests/join_denied.rs | 2 +- .../tests/join_snapshot_load.rs | 2 +- .../tests/readonly_receive.rs | 2 +- .../tests/reject_update_without_join.rs | 2 +- 10 files changed, 27 insertions(+), 28 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index f852784..6aa1548 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -108,7 +108,7 @@ type AuthFuture = Pin, String>> + Send + 'static>>; type AuthFn = Arc) -> AuthFuture + Send + Sync>; -type HandshakeAuthFn = dyn Fn(&str, Option<&str>, &HashMap) -> bool + Send + Sync; +type HandshakeAuthFn = dyn Fn(&str, Option<&str>, &tungstenite::handshake::server::Request) -> bool + Send + Sync; #[derive(Clone)] pub struct ServerConfig { @@ -122,7 +122,7 @@ pub struct ServerConfig { /// Parameters: /// - `workspace_id`: extracted from request path `/{workspace}` (empty if missing) /// - `token`: `token` query parameter if present - /// - `cookies`: parsed cookies from `Cookie` header + /// - `request`: the full HTTP request (headers, uri, etc) /// /// Return true to accept, false to reject with 401. pub handshake_auth: Option>, @@ -926,19 +926,7 @@ where None }); - // Parse cookies - let mut cookies = HashMap::new(); - if let Some(header) = req.headers().get("Cookie") { - if let Ok(s) = header.to_str() { - for cookie in cookie::Cookie::split_parse(s) { - if let Ok(c) = cookie { - cookies.insert(c.name().to_string(), c.value().to_string()); - } - } - } - } - - let allowed = (check)(workspace_id, token, &cookies); + let allowed = (check)(workspace_id, token, req); if !allowed { warn!(workspace=%workspace_id, token=?token, "handshake auth denied"); // Build a 401 Unauthorized response diff --git a/rust/loro-websocket-server/tests/e2e.rs b/rust/loro-websocket-server/tests/e2e.rs index 4d0b277..e10fe25 100644 --- a/rust/loro-websocket-server/tests/e2e.rs +++ b/rust/loro-websocket-server/tests/e2e.rs @@ -14,7 +14,7 @@ async fn e2e_sync_two_clients_docupdate_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -65,7 +65,7 @@ async fn workspaces_are_isolated() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -104,7 +104,7 @@ async fn e2e_sync_two_clients_loro_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -154,7 +154,7 @@ async fn e2e_sync_two_clients_elo_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs index 7968208..ad2c010 100644 --- a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs +++ b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs @@ -11,7 +11,7 @@ async fn elo_accepts_join_and_broadcasts_updates() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs index ba28c0f..c0f0eba 100644 --- a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs +++ b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs @@ -19,7 +19,7 @@ async fn elo_fragment_reassembly_broadcasts_original_frames() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_auth.rs b/rust/loro-websocket-server/tests/handshake_auth.rs index 20ce01b..b27ab4c 100644 --- a/rust/loro-websocket-server/tests/handshake_auth.rs +++ b/rust/loro-websocket-server/tests/handshake_auth.rs @@ -9,7 +9,7 @@ async fn handshake_rejects_invalid_token_with_401() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_cookies.rs b/rust/loro-websocket-server/tests/handshake_cookies.rs index b520a67..698f455 100644 --- a/rust/loro-websocket-server/tests/handshake_cookies.rs +++ b/rust/loro-websocket-server/tests/handshake_cookies.rs @@ -10,8 +10,19 @@ async fn handshake_auth_can_read_cookies() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, _token, cookies| { - cookies.get("session").map(|v| v.as_str()) == Some("valid") + handshake_auth: Some(Arc::new(|_ws, _token, req| { + if let Some(header) = req.headers().get("Cookie") { + if let Ok(s) = header.to_str() { + for cookie in cookie::Cookie::split_parse(s) { + if let Ok(c) = cookie { + if c.name() == "session" && c.value() == "valid" { + return true; + } + } + } + } + } + false })), ..Default::default() }; diff --git a/rust/loro-websocket-server/tests/join_denied.rs b/rust/loro-websocket-server/tests/join_denied.rs index 5d3b9ef..9b1e6a4 100644 --- a/rust/loro-websocket-server/tests/join_denied.rs +++ b/rust/loro-websocket-server/tests/join_denied.rs @@ -13,7 +13,7 @@ async fn join_denied_returns_error() { let cfg: server::ServerConfig<()> = server::ServerConfig { authenticate: Some(Arc::new(|_room, _crdt, _auth| Box::pin(async { Ok(None) }))), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/join_snapshot_load.rs b/rust/loro-websocket-server/tests/join_snapshot_load.rs index 37caa4e..d69a68a 100644 --- a/rust/loro-websocket-server/tests/join_snapshot_load.rs +++ b/rust/loro-websocket-server/tests/join_snapshot_load.rs @@ -24,7 +24,7 @@ async fn join_sends_snapshot_from_loader() { }) }) })), - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/readonly_receive.rs b/rust/loro-websocket-server/tests/readonly_receive.rs index 4b5ee91..33ee2d6 100644 --- a/rust/loro-websocket-server/tests/readonly_receive.rs +++ b/rust/loro-websocket-server/tests/readonly_receive.rs @@ -24,7 +24,7 @@ async fn readonly_receives_updates_writer_sends() { }) })), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/reject_update_without_join.rs b/rust/loro-websocket-server/tests/reject_update_without_join.rs index 50fac08..ac1f859 100644 --- a/rust/loro-websocket-server/tests/reject_update_without_join.rs +++ b/rust/loro-websocket-server/tests/reject_update_without_join.rs @@ -9,7 +9,7 @@ async fn reject_update_without_join() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _cookies| token == Some("secret"))), + handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) From 6122a3a24b12ec60e6ef2fc0c6887b59d1fcb49c Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Thu, 18 Dec 2025 00:45:45 +0100 Subject: [PATCH 04/15] Exposing conn_id to hooks to recognize user sessions --- rust/loro-websocket-server/src/lib.rs | 44 ++++++++++++++++--- rust/loro-websocket-server/tests/e2e.rs | 8 ++-- .../tests/elo_accept_broadcast.rs | 2 +- .../tests/elo_fragment_reassembly.rs | 2 +- .../tests/handshake_auth.rs | 2 +- .../tests/handshake_cookies.rs | 4 +- .../tests/join_denied.rs | 4 +- .../tests/join_snapshot_load.rs | 2 +- .../tests/readonly_receive.rs | 8 ++-- .../tests/reject_update_without_join.rs | 2 +- 10 files changed, 56 insertions(+), 22 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 6aa1548..00be5ba 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -104,11 +104,28 @@ type LoadFuture = type SaveFuture = Pin> + Send + 'static>>; type LoadFn = Arc LoadFuture + Send + Sync>; type SaveFn = Arc) -> SaveFuture + Send + Sync>; + +/// Arguments provided to `authenticate`. +pub struct AuthArgs { + pub room: String, + pub crdt: CrdtType, + pub auth: Vec, + pub conn_id: u64, +} + type AuthFuture = Pin, String>> + Send + 'static>>; -type AuthFn = Arc) -> AuthFuture + Send + Sync>; +type AuthFn = Arc AuthFuture + Send + Sync>; + +/// Arguments provided to `handshake_auth`. +pub struct HandshakeAuthArgs<'a> { + pub workspace: &'a str, + pub token: Option<&'a str>, + pub request: &'a tungstenite::handshake::server::Request, + pub conn_id: u64, +} -type HandshakeAuthFn = dyn Fn(&str, Option<&str>, &tungstenite::handshake::server::Request) -> bool + Send + Sync; +type HandshakeAuthFn = dyn Fn(HandshakeAuthArgs) -> bool + Send + Sync; #[derive(Clone)] pub struct ServerConfig { @@ -123,6 +140,7 @@ pub struct ServerConfig { /// - `workspace_id`: extracted from request path `/{workspace}` (empty if missing) /// - `token`: `token` query parameter if present /// - `request`: the full HTTP request (headers, uri, etc) + /// - `conn_id`: the connection id /// /// Return true to accept, false to reject with 401. pub handshake_auth: Option>, @@ -885,12 +903,17 @@ async fn handle_conn( where DocCtx: Clone + Send + Sync + 'static, { + + // Generate a connection id + let conn_id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + // Capture config outside of non-async closure let handshake_auth = registry.config.handshake_auth.clone(); let workspace_holder: Arc>> = Arc::new(std::sync::Mutex::new(None)); let workspace_holder_c = workspace_holder.clone(); + let ws = accept_hdr_async( stream, move |req: &tungstenite::handshake::server::Request, @@ -926,7 +949,12 @@ where None }); - let allowed = (check)(workspace_id, token, req); + let allowed = (check)(HandshakeAuthArgs { + workspace: workspace_id, + token, + request: req, + conn_id, + }); if !allowed { warn!(workspace=%workspace_id, token=?token, "handshake auth denied"); // Build a 401 Unauthorized response @@ -972,7 +1000,6 @@ where } }); - let conn_id = NEXT_ID.fetch_add(1, Ordering::Relaxed); let mut joined_rooms: HashSet = HashSet::new(); while let Some(msg) = stream.next().await { @@ -1002,7 +1029,14 @@ where let mut permission = h.config.default_permission; if let Some(auth_fn) = &h.config.authenticate { let room_str = room.room.clone(); - match (auth_fn)(room_str, room.crdt, auth.clone()).await { + match (auth_fn)(AuthArgs { + room: room_str, + crdt: room.crdt, + auth: auth.clone(), + conn_id, + }) + .await + { Ok(Some(p)) => { permission = p; } diff --git a/rust/loro-websocket-server/tests/e2e.rs b/rust/loro-websocket-server/tests/e2e.rs index e10fe25..4646204 100644 --- a/rust/loro-websocket-server/tests/e2e.rs +++ b/rust/loro-websocket-server/tests/e2e.rs @@ -14,7 +14,7 @@ async fn e2e_sync_two_clients_docupdate_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -65,7 +65,7 @@ async fn workspaces_are_isolated() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -104,7 +104,7 @@ async fn e2e_sync_two_clients_loro_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) @@ -154,7 +154,7 @@ async fn e2e_sync_two_clients_elo_adaptor_roundtrip() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: Cfg = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs index ad2c010..c0605fa 100644 --- a/rust/loro-websocket-server/tests/elo_accept_broadcast.rs +++ b/rust/loro-websocket-server/tests/elo_accept_broadcast.rs @@ -11,7 +11,7 @@ async fn elo_accepts_join_and_broadcasts_updates() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs index c0f0eba..e86c21d 100644 --- a/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs +++ b/rust/loro-websocket-server/tests/elo_fragment_reassembly.rs @@ -19,7 +19,7 @@ async fn elo_fragment_reassembly_broadcasts_original_frames() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_auth.rs b/rust/loro-websocket-server/tests/handshake_auth.rs index b27ab4c..fde5939 100644 --- a/rust/loro-websocket-server/tests/handshake_auth.rs +++ b/rust/loro-websocket-server/tests/handshake_auth.rs @@ -9,7 +9,7 @@ async fn handshake_rejects_invalid_token_with_401() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) diff --git a/rust/loro-websocket-server/tests/handshake_cookies.rs b/rust/loro-websocket-server/tests/handshake_cookies.rs index 698f455..a66af00 100644 --- a/rust/loro-websocket-server/tests/handshake_cookies.rs +++ b/rust/loro-websocket-server/tests/handshake_cookies.rs @@ -10,8 +10,8 @@ async fn handshake_auth_can_read_cookies() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, _token, req| { - if let Some(header) = req.headers().get("Cookie") { + handshake_auth: Some(Arc::new(|args| { + if let Some(header) = args.request.headers().get("Cookie") { if let Ok(s) = header.to_str() { for cookie in cookie::Cookie::split_parse(s) { if let Ok(c) = cookie { diff --git a/rust/loro-websocket-server/tests/join_denied.rs b/rust/loro-websocket-server/tests/join_denied.rs index 9b1e6a4..bab1e09 100644 --- a/rust/loro-websocket-server/tests/join_denied.rs +++ b/rust/loro-websocket-server/tests/join_denied.rs @@ -11,9 +11,9 @@ async fn join_denied_returns_error() { // Server with auth that always denies let cfg: server::ServerConfig<()> = server::ServerConfig { - authenticate: Some(Arc::new(|_room, _crdt, _auth| Box::pin(async { Ok(None) }))), + authenticate: Some(Arc::new(|_args| Box::pin(async { Ok(None) }))), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/join_snapshot_load.rs b/rust/loro-websocket-server/tests/join_snapshot_load.rs index d69a68a..163648b 100644 --- a/rust/loro-websocket-server/tests/join_snapshot_load.rs +++ b/rust/loro-websocket-server/tests/join_snapshot_load.rs @@ -24,7 +24,7 @@ async fn join_sends_snapshot_from_loader() { }) }) })), - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/readonly_receive.rs b/rust/loro-websocket-server/tests/readonly_receive.rs index 33ee2d6..e03789e 100644 --- a/rust/loro-websocket-server/tests/readonly_receive.rs +++ b/rust/loro-websocket-server/tests/readonly_receive.rs @@ -12,11 +12,11 @@ async fn readonly_receives_updates_writer_sends() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let cfg: server::ServerConfig<()> = server::ServerConfig { - authenticate: Some(Arc::new(|_room, _crdt, auth| { + authenticate: Some(Arc::new(|args| { Box::pin(async move { - if auth == b"writer" { + if args.auth == b"writer" { Ok(Some(Permission::Write)) - } else if auth == b"reader" { + } else if args.auth == b"reader" { Ok(Some(Permission::Read)) } else { Ok(None) @@ -24,7 +24,7 @@ async fn readonly_receives_updates_writer_sends() { }) })), default_permission: Permission::Write, - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; let server_task = tokio::spawn(async move { diff --git a/rust/loro-websocket-server/tests/reject_update_without_join.rs b/rust/loro-websocket-server/tests/reject_update_without_join.rs index ac1f859..1bdf417 100644 --- a/rust/loro-websocket-server/tests/reject_update_without_join.rs +++ b/rust/loro-websocket-server/tests/reject_update_without_join.rs @@ -9,7 +9,7 @@ async fn reject_update_without_join() { let addr = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { let cfg: server::ServerConfig<()> = server::ServerConfig { - handshake_auth: Some(Arc::new(|_ws, token, _req| token == Some("secret"))), + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), ..Default::default() }; server::serve_incoming_with_config(listener, cfg) From 0e875bb6627694f41b7751700d65525a3f86c80c Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Tue, 30 Dec 2025 17:46:32 +0100 Subject: [PATCH 05/15] Added support for a new "on_close_connection" hook. --- rust/loro-websocket-server/src/lib.rs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 00be5ba..02fc505 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -127,6 +127,18 @@ pub struct HandshakeAuthArgs<'a> { type HandshakeAuthFn = dyn Fn(HandshakeAuthArgs) -> bool + Send + Sync; +/// Arguments provided to `on_close_connection`. +pub struct CloseConnectionArgs { + pub workspace: String, + pub conn_id: u64, + pub rooms: Vec<(CrdtType, String)>, +} + +type CloseConnectionFuture = + Pin> + Send + 'static>>; +type CloseConnectionFn = + Arc CloseConnectionFuture + Send + Sync>; + #[derive(Clone)] pub struct ServerConfig { pub on_load_document: Option>, @@ -144,6 +156,9 @@ pub struct ServerConfig { /// /// Return true to accept, false to reject with 401. pub handshake_auth: Option>, + /// Optional hook invoked after a connection fully closes. + /// Receives the workspace id, connection id, and rooms the client had joined. + pub on_close_connection: Option, } // CRDT document abstraction to reduce match-based branching @@ -459,6 +474,7 @@ impl Default for ServerConfig { default_permission: Permission::Write, authenticate: None, handshake_auth: None, + on_close_connection: None, } } } @@ -909,6 +925,7 @@ where // Capture config outside of non-async closure let handshake_auth = registry.config.handshake_auth.clone(); + let close_connection = registry.config.on_close_connection.clone(); let workspace_holder: Arc>> = Arc::new(std::sync::Mutex::new(None)); let workspace_holder_c = workspace_holder.clone(); @@ -1422,6 +1439,11 @@ where } } + let rooms_for_hook: Vec<(CrdtType, String)> = joined_rooms + .into_iter() + .map(|RoomKey { crdt, room }| (crdt, room)) + .collect(); + // cleanup { let mut h = hub.lock().await; @@ -1430,6 +1452,18 @@ where // drop tx to stop writer drop(tx); let _ = sink_task.await; + + if let Some(hook) = close_connection { + let args = CloseConnectionArgs { + workspace: workspace_id.clone(), + conn_id, + rooms: rooms_for_hook, + }; + if let Err(e) = (hook)(args).await { + warn!(conn_id, %e, "on_close_connection hook failed"); + } + } + debug!(conn_id, "connection closed and cleaned up"); Ok(()) } From da4da596f2dbdd88c37e01260f8ad81fdb244f09 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Sun, 4 Jan 2026 15:09:58 +0100 Subject: [PATCH 06/15] Add initial support for update hook --- rust/loro-websocket-server/src/lib.rs | 46 ++++++++ .../loro-websocket-server/tests/close_hook.rs | 90 ++++++++++++++ .../tests/update_hook.rs | 111 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 rust/loro-websocket-server/tests/close_hook.rs create mode 100644 rust/loro-websocket-server/tests/update_hook.rs diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 02fc505..7cb182b 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -105,6 +105,19 @@ type SaveFuture = Pin> + Send + 'stat type LoadFn = Arc LoadFuture + Send + Sync>; type SaveFn = Arc) -> SaveFuture + Send + Sync>; +/// Arguments provided to `on_update`. +pub struct UpdateArgs { + pub workspace: String, + pub room: String, + pub crdt: CrdtType, + pub conn_id: u64, + pub updates: Vec>, + pub ctx: Option, +} + +type UpdateFuture = Pin + Send + 'static>>; +type UpdateFn = Arc) -> UpdateFuture + Send + Sync>; + /// Arguments provided to `authenticate`. pub struct AuthArgs { pub room: String, @@ -143,6 +156,7 @@ type CloseConnectionFn = pub struct ServerConfig { pub on_load_document: Option>, pub on_save_document: Option>, + pub on_update: Option>, pub save_interval_ms: Option, pub default_permission: Permission, pub authenticate: Option, @@ -470,6 +484,7 @@ impl Default for ServerConfig { Self { on_load_document: None, on_save_document: None, + on_update: None, save_interval_ms: None, default_permission: Permission::Write, authenticate: None, @@ -1297,6 +1312,21 @@ where if let Some(buf) = h.add_fragment_and_maybe_finish(&room, batch_id, index, fragment) { + if let Some(hook) = h.config.on_update.clone() { + let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); + drop(h); + let args = UpdateArgs { + workspace: workspace_id.clone(), + room: room.room.clone(), + crdt, + conn_id, + updates: vec![buf.clone()], + ctx, + }; + (hook)(args).await; + h = hub.lock().await; + } + // On completion: parse and apply to stored doc state if applicable let apply_result = match crdt { CrdtType::Loro @@ -1374,6 +1404,22 @@ where continue; } let mut h = hub.lock().await; + + if let Some(hook) = h.config.on_update.clone() { + let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); + drop(h); + let args = UpdateArgs { + workspace: workspace_id.clone(), + room: room.room.clone(), + crdt, + conn_id, + updates: updates.clone(), + ctx, + }; + (hook)(args).await; + h = hub.lock().await; + } + let apply_result = match crdt { CrdtType::Loro | CrdtType::LoroEphemeralStore diff --git a/rust/loro-websocket-server/tests/close_hook.rs b/rust/loro-websocket-server/tests/close_hook.rs new file mode 100644 index 0000000..e2f3675 --- /dev/null +++ b/rust/loro-websocket-server/tests/close_hook.rs @@ -0,0 +1,90 @@ +use loro_websocket_client::Client; +use loro_websocket_server as server; +use server::protocol::{CrdtType, ProtocolMessage}; +use std::sync::Arc; +use tokio::sync::{Mutex, Notify}; + +type Cfg = server::ServerConfig<()>; + +#[derive(Clone, Debug)] +struct CloseRecord { + workspace: String, + conn_id: u64, + rooms: Vec<(CrdtType, String)>, +} + +#[tokio::test(flavor = "current_thread")] +async fn on_close_connection_receives_workspace_and_rooms() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind tcp listener"); + let addr = listener.local_addr().expect("local addr"); + + let close_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let notify = Arc::new(Notify::new()); + + let close_calls_cfg = close_calls.clone(); + let notify_cfg = notify.clone(); + + let server_task = tokio::spawn(async move { + let cfg: Cfg = server::ServerConfig { + handshake_auth: Some(Arc::new(|args| args.token == Some("secret"))), + on_close_connection: Some(Arc::new(move |args: server::CloseConnectionArgs| { + let close_calls = close_calls_cfg.clone(); + let notify = notify_cfg.clone(); + Box::pin(async move { + let server::CloseConnectionArgs { + workspace, + conn_id, + rooms, + } = args; + close_calls.lock().await.push(CloseRecord { + workspace, + conn_id, + rooms, + }); + notify.notify_waiters(); + Ok(()) + }) + })), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .expect("serve incoming"); + }); + + let url = format!("ws://{}/ws-close?token=secret", addr); + let mut client = Client::connect(&url).await.expect("connect client"); + let room_id = "close-room"; + let join = ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: room_id.to_string(), + auth: Vec::new(), + version: Vec::new(), + }; + client.send(&join).await.expect("send join"); + match client.next().await.expect("join response") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {} + other => panic!("unexpected response: {:?}", other), + } + + let notified = notify.notified(); + client.close().await.expect("close client"); + + tokio::time::timeout(std::time::Duration::from_secs(2), notified) + .await + .expect("close hook not called in time"); + + let calls = close_calls.lock().await; + assert_eq!(calls.len(), 1, "expected exactly one close hook call"); + let record = &calls[0]; + assert_eq!(record.workspace, "ws-close"); + assert!(record.conn_id > 0); + assert_eq!(record.rooms.len(), 1); + let (crdt, room) = &record.rooms[0]; + assert_eq!(*crdt, CrdtType::Loro); + assert_eq!(room, room_id); + + server_task.abort(); +} diff --git a/rust/loro-websocket-server/tests/update_hook.rs b/rust/loro-websocket-server/tests/update_hook.rs new file mode 100644 index 0000000..60b1f31 --- /dev/null +++ b/rust/loro-websocket-server/tests/update_hook.rs @@ -0,0 +1,111 @@ +use loro_websocket_client::Client; +use loro_websocket_server as server; +use server::protocol::{CrdtType, ProtocolMessage}; +use std::sync::Arc; +use tokio::sync::{Mutex, Notify}; + +type Cfg = server::ServerConfig<()>; + +#[derive(Clone, Debug)] +struct UpdateRecord { + workspace: String, + room: String, + crdt: CrdtType, + conn_id: u64, + updates_len: usize, +} + +#[tokio::test(flavor = "current_thread")] +async fn on_update_hook_called() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind tcp listener"); + let addr = listener.local_addr().expect("local addr"); + + let update_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let notify = Arc::new(Notify::new()); + + let update_calls_cfg = update_calls.clone(); + let notify_cfg = notify.clone(); + + let server_task = tokio::spawn(async move { + let cfg: Cfg = server::ServerConfig { + on_update: Some(Arc::new(move |args: server::UpdateArgs<()>| { + let update_calls = update_calls_cfg.clone(); + let notify = notify_cfg.clone(); + Box::pin(async move { + let server::UpdateArgs { + workspace, + room, + crdt, + conn_id, + updates, + ctx: _, + } = args; + update_calls.lock().await.push(UpdateRecord { + workspace, + room, + crdt, + conn_id, + updates_len: updates.len(), + }); + notify.notify_waiters(); + }) + })), + // Use handshake auth to ensure workspace_id is captured + handshake_auth: Some(Arc::new(|_| true)), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + }); + + // Connect client + let url = format!("ws://{}/my-workspace", addr); + let mut client = Client::connect(&url).await.expect("connect"); + + // Join room + client + .send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "room1".to_string(), + auth: vec![], + version: vec![], + }) + .await + .expect("send join"); + + // Wait for join response + match client.next().await.expect("recv") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {} + msg => panic!("unexpected msg: {:?}", msg), + } + + // Send update + let update_payload = vec![1, 2, 3, 4]; + client + .send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "room1".to_string(), + updates: vec![update_payload.clone()], + batch_id: server::protocol::BatchId([0; 8]), // dummy batch id + }) + .await + .expect("send update"); + + // Wait for hook to be called + notify.notified().await; + + let calls = update_calls.lock().await; + assert_eq!(calls.len(), 1); + let record = &calls[0]; + assert_eq!(record.workspace, "my-workspace"); + assert_eq!(record.room, "room1"); + assert_eq!(record.crdt, CrdtType::Loro); + assert_eq!(record.updates_len, 1); + // conn_id is dynamic, just check it's non-zero + assert!(record.conn_id > 0); + + server_task.abort(); +} From 18a5fdcdf06a3ad2d8ec0d9781d8abf9121e7f42 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Sun, 4 Jan 2026 17:08:43 +0100 Subject: [PATCH 07/15] Support blocking of updates through on_update hook --- rust/loro-websocket-server/src/lib.rs | 29 ++++- .../tests/update_hook.rs | 120 +++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 7cb182b..060035f 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -112,10 +112,11 @@ pub struct UpdateArgs { pub crdt: CrdtType, pub conn_id: u64, pub updates: Vec>, + pub doc: Option, pub ctx: Option, } -type UpdateFuture = Pin + Send + 'static>>; +type UpdateFuture = Pin + Send + 'static>>; type UpdateFn = Arc) -> UpdateFuture + Send + Sync>; /// Arguments provided to `authenticate`. @@ -199,6 +200,9 @@ trait CrdtDoc: Send { fn remove_when_last_subscriber_leaves(&self) -> bool { false } + fn get_loro_doc(&self) -> Option { + None + } } struct LoroRoomDoc { @@ -227,6 +231,9 @@ impl CrdtDoc for LoroRoomDoc { fn import_snapshot(&mut self, data: &[u8]) { let _ = self.doc.import(data); } + fn get_loro_doc(&self) -> Option { + Some(self.doc.clone()) + } } struct EphemeralRoomDoc { @@ -1312,8 +1319,10 @@ where if let Some(buf) = h.add_fragment_and_maybe_finish(&room, batch_id, index, fragment) { + let mut hook_result = UpdateStatusCode::Ok; if let Some(hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); + let doc = h.docs.get(&room).and_then(|s| s.doc.get_loro_doc()); drop(h); let args = UpdateArgs { workspace: workspace_id.clone(), @@ -1321,12 +1330,18 @@ where crdt, conn_id, updates: vec![buf.clone()], + doc, ctx, }; - (hook)(args).await; + hook_result = (hook)(args).await; h = hub.lock().await; } + if hook_result != UpdateStatusCode::Ok { + send_ack(&tx, crdt, &room.room, batch_id, hook_result); + continue; + } + // On completion: parse and apply to stored doc state if applicable let apply_result = match crdt { CrdtType::Loro @@ -1405,8 +1420,10 @@ where } let mut h = hub.lock().await; + let mut hook_result = UpdateStatusCode::Ok; if let Some(hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); + let doc = h.docs.get(&room).and_then(|s| s.doc.get_loro_doc()); drop(h); let args = UpdateArgs { workspace: workspace_id.clone(), @@ -1414,12 +1431,18 @@ where crdt, conn_id, updates: updates.clone(), + doc, ctx, }; - (hook)(args).await; + hook_result = (hook)(args).await; h = hub.lock().await; } + if hook_result != UpdateStatusCode::Ok { + send_ack(&tx, crdt, &room.room, batch_id, hook_result); + continue; + } + let apply_result = match crdt { CrdtType::Loro | CrdtType::LoroEphemeralStore diff --git a/rust/loro-websocket-server/tests/update_hook.rs b/rust/loro-websocket-server/tests/update_hook.rs index 60b1f31..e188d6f 100644 --- a/rust/loro-websocket-server/tests/update_hook.rs +++ b/rust/loro-websocket-server/tests/update_hook.rs @@ -1,6 +1,6 @@ use loro_websocket_client::Client; use loro_websocket_server as server; -use server::protocol::{CrdtType, ProtocolMessage}; +use server::protocol::{CrdtType, ProtocolMessage, UpdateStatusCode}; use std::sync::Arc; use tokio::sync::{Mutex, Notify}; @@ -40,6 +40,7 @@ async fn on_update_hook_called() { crdt, conn_id, updates, + doc: _, ctx: _, } = args; update_calls.lock().await.push(UpdateRecord { @@ -50,6 +51,7 @@ async fn on_update_hook_called() { updates_len: updates.len(), }); notify.notify_waiters(); + UpdateStatusCode::Ok }) })), // Use handshake auth to ensure workspace_id is captured @@ -109,3 +111,119 @@ async fn on_update_hook_called() { server_task.abort(); } + +#[tokio::test(flavor = "current_thread")] +async fn on_update_hook_can_reject() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind tcp listener"); + let addr = listener.local_addr().expect("local addr"); + + let server_task = tokio::spawn(async move { + let cfg: Cfg = server::ServerConfig { + on_update: Some(Arc::new(move |args: server::UpdateArgs<()>| { + Box::pin(async move { + if args.room == "rejected" { + UpdateStatusCode::PermissionDenied + } else { + UpdateStatusCode::Ok + } + }) + })), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + }); + + let url = format!("ws://{}/workspace", addr); + let mut c1 = Client::connect(&url).await.expect("c1 connect"); + let mut c2 = Client::connect(&url).await.expect("c2 connect"); + + // 1. Both join "rejected" + for c in [&mut c1, &mut c2] { + c.send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "rejected".to_string(), + auth: vec![], + version: vec![], + }).await.expect("join rejected"); + match c.next().await.expect("recv") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {}, + m => panic!("unexpected join response: {:?}", m), + } + // Consume snapshot + match c.next().await.expect("recv") { + Some(ProtocolMessage::DocUpdate { .. }) => {}, + m => panic!("expected initial snapshot, got: {:?}", m), + } + } + + // 2. C1 sends update to "rejected" -> Should be rejected + let batch_id_1 = server::protocol::BatchId([1; 8]); + c1.send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "rejected".to_string(), + updates: vec![vec![1, 2, 3]], + batch_id: batch_id_1, + }).await.expect("send update 1"); + + // C1 gets Ack(PermissionDenied) + match c1.next().await.expect("recv") { + Some(ProtocolMessage::Ack { ref_id, status, .. }) => { + assert_eq!(ref_id, batch_id_1); + assert_eq!(status, UpdateStatusCode::PermissionDenied); + } + m => panic!("unexpected msg c1: {:?}", m), + } + + // 3. Both join "accepted" + for c in [&mut c1, &mut c2] { + c.send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "accepted".to_string(), + auth: vec![], + version: vec![], + }).await.expect("join accepted"); + match c.next().await.expect("recv") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {}, + m => panic!("unexpected join response: {:?}", m), + } + // Consume snapshot + match c.next().await.expect("recv") { + Some(ProtocolMessage::DocUpdate { .. }) => {}, + m => panic!("expected initial snapshot, got: {:?}", m), + } + } + + // 4. C1 sends update to "accepted" -> Should be accepted + let batch_id_2 = server::protocol::BatchId([2; 8]); + c1.send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "accepted".to_string(), + updates: vec![vec![4, 5, 6]], + batch_id: batch_id_2, + }).await.expect("send update 2"); + + // C1 gets Ack(Ok) + match c1.next().await.expect("recv") { + Some(ProtocolMessage::Ack { ref_id, status, .. }) => { + assert_eq!(ref_id, batch_id_2); + assert_eq!(status, UpdateStatusCode::Ok); + } + m => panic!("unexpected msg c1: {:?}", m), + } + + // 5. C2 should receive the update for "accepted". + // Crucially, it should NOT have received the update for "rejected" before this. + match c2.next().await.expect("recv") { + Some(ProtocolMessage::DocUpdate { room_id, batch_id, .. }) => { + assert_eq!(room_id, "accepted"); + assert_eq!(batch_id, batch_id_2); + } + m => panic!("unexpected msg c2: {:?}", m), + } + + server_task.abort(); +} From 6f0801e5c8f97783ece77d5c2f5dfec05984f8d9 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Sun, 4 Jan 2026 23:19:47 +0100 Subject: [PATCH 08/15] Don't call 'on_update' for empty updates. --- rust/loro-websocket-server/src/lib.rs | 10 ++ .../tests/update_hook.rs | 104 ++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 060035f..0460ad9 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -1319,6 +1319,11 @@ where if let Some(buf) = h.add_fragment_and_maybe_finish(&room, batch_id, index, fragment) { + if buf.is_empty() { + send_ack(&tx, crdt, &room.room, batch_id, UpdateStatusCode::Ok); + continue; + } + let mut hook_result = UpdateStatusCode::Ok; if let Some(hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); @@ -1420,6 +1425,11 @@ where } let mut h = hub.lock().await; + if updates.iter().all(|u| u.is_empty()) { + send_ack(&tx, crdt, &room.room, batch_id, UpdateStatusCode::Ok); + continue; + } + let mut hook_result = UpdateStatusCode::Ok; if let Some(hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); diff --git a/rust/loro-websocket-server/tests/update_hook.rs b/rust/loro-websocket-server/tests/update_hook.rs index e188d6f..1370ab9 100644 --- a/rust/loro-websocket-server/tests/update_hook.rs +++ b/rust/loro-websocket-server/tests/update_hook.rs @@ -3,6 +3,7 @@ use loro_websocket_server as server; use server::protocol::{CrdtType, ProtocolMessage, UpdateStatusCode}; use std::sync::Arc; use tokio::sync::{Mutex, Notify}; +use std::time::Duration; type Cfg = server::ServerConfig<()>; @@ -227,3 +228,106 @@ async fn on_update_hook_can_reject() { server_task.abort(); } + +#[tokio::test(flavor = "current_thread")] +async fn on_update_hook_not_called_for_empty_update() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind tcp listener"); + let addr = listener.local_addr().expect("local addr"); + + let update_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let notify = Arc::new(Notify::new()); + + let update_calls_cfg = update_calls.clone(); + let notify_cfg = notify.clone(); + + let server_task = tokio::spawn(async move { + let cfg: Cfg = server::ServerConfig { + on_update: Some(Arc::new(move |args: server::UpdateArgs<()>| { + let update_calls = update_calls_cfg.clone(); + let notify = notify_cfg.clone(); + Box::pin(async move { + let server::UpdateArgs { + workspace, + room, + crdt, + conn_id, + updates, + doc: _, + ctx: _, + } = args; + update_calls.lock().await.push(UpdateRecord { + workspace, + room, + crdt, + conn_id, + updates_len: updates.len(), + }); + notify.notify_waiters(); + UpdateStatusCode::Ok + }) + })), + handshake_auth: Some(Arc::new(|_| true)), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + }); + + // Connect client + let url = format!("ws://{}/my-workspace", addr); + let mut client = Client::connect(&url).await.expect("connect"); + + // Join room + client + .send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "room1".to_string(), + auth: vec![], + version: vec![], + }) + .await + .expect("send join"); + + // Wait for join response + match client.next().await.expect("recv") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {} + msg => panic!("unexpected msg: {:?}", msg), + } + + // Consume initial snapshot + match client.next().await.expect("recv") { + Some(ProtocolMessage::DocUpdate { .. }) => {} + msg => panic!("unexpected msg: {:?}", msg), + } + + // Send EMPTY update + client + .send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "room1".to_string(), + updates: vec![vec![]], // Empty update + batch_id: server::protocol::BatchId([1; 8]), + }) + .await + .expect("send update"); + + // Wait for Ack + match client.next().await.expect("recv") { + Some(ProtocolMessage::Ack { ref_id, status, .. }) => { + assert_eq!(ref_id, server::protocol::BatchId([1; 8])); + assert_eq!(status, UpdateStatusCode::Ok); + } + msg => panic!("unexpected msg: {:?}", msg), + } + + // Verify hook was NOT called + // We wait a bit to be sure + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(update_calls.lock().await.is_empty()); + + server_task.abort(); +} + From 519d850651d7a02b1b703f3de1bbfd3fb0360db5 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Mon, 5 Jan 2026 13:14:04 +0100 Subject: [PATCH 09/15] New on_update hook return type to support ctx and direct doc updates. --- rust/loro-websocket-server/src/lib.rs | 153 ++++++---- .../tests/update_hook.rs | 263 +++++++++++++----- 2 files changed, 292 insertions(+), 124 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 0460ad9..66c4ab5 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -116,8 +116,15 @@ pub struct UpdateArgs { pub ctx: Option, } -type UpdateFuture = Pin + Send + 'static>>; -type UpdateFn = Arc) -> UpdateFuture + Send + Sync>; +pub struct UpdatedDoc { + pub status: UpdateStatusCode, + pub ctx: Option, + pub doc: Option, +} + +type UpdateFuture = + Pin> + Send + 'static>>; +type UpdateFn = Arc) -> UpdateFuture + Send + Sync>; /// Arguments provided to `authenticate`. pub struct AuthArgs { @@ -203,6 +210,9 @@ trait CrdtDoc: Send { fn get_loro_doc(&self) -> Option { None } + fn set_loro_doc(&mut self, _doc: LoroDoc) -> bool { + false + } } struct LoroRoomDoc { @@ -234,6 +244,10 @@ impl CrdtDoc for LoroRoomDoc { fn get_loro_doc(&self) -> Option { Some(self.doc.clone()) } + fn set_loro_doc(&mut self, doc: LoroDoc) -> bool { + self.doc = doc; + true + } } struct EphemeralRoomDoc { @@ -723,6 +737,32 @@ where Some(data) } } + + fn process_update_hook_result( + &mut self, + room: &RoomKey, + result: &mut UpdatedDoc, + ) -> bool { + let mut replaced_doc = false; + if result.ctx.is_some() || result.doc.is_some() { + if let Some(state) = self.docs.get_mut(room) { + if let Some(new_ctx) = result.ctx.take() { + state.ctx = Some(new_ctx); + } + if result.status == UpdateStatusCode::Ok { + if let Some(new_doc) = result.doc.take() { + if state.doc.set_loro_doc(new_doc) { + state.dirty = true; + replaced_doc = true; + } + } + } else { + result.doc = None; + } + } + } + replaced_doc + } } struct FragmentBatch { @@ -1319,13 +1359,9 @@ where if let Some(buf) = h.add_fragment_and_maybe_finish(&room, batch_id, index, fragment) { - if buf.is_empty() { - send_ack(&tx, crdt, &room.room, batch_id, UpdateStatusCode::Ok); - continue; - } - let mut hook_result = UpdateStatusCode::Ok; - if let Some(hook) = h.config.on_update.clone() { + let mut skip_apply = false; + if let Some(update_hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); let doc = h.docs.get(&room).and_then(|s| s.doc.get_loro_doc()); drop(h); @@ -1338,33 +1374,37 @@ where doc, ctx, }; - hook_result = (hook)(args).await; + let mut update_hook_result = (update_hook)(args).await; h = hub.lock().await; - } - - if hook_result != UpdateStatusCode::Ok { - send_ack(&tx, crdt, &room.room, batch_id, hook_result); - continue; + skip_apply = h.process_update_hook_result(&room, &mut update_hook_result); + if update_hook_result.status != UpdateStatusCode::Ok { + send_ack(&tx, crdt, &room.room, batch_id, update_hook_result.status); + continue; + } } // On completion: parse and apply to stored doc state if applicable - let apply_result = match crdt { - CrdtType::Loro - | CrdtType::LoroEphemeralStore - | CrdtType::LoroEphemeralStorePersisted => { - let start = std::time::Instant::now(); - let res = h.apply_updates(&room, &[buf.clone()]); - let elapsed_ms = start.elapsed().as_millis(); - if res.is_ok() { - debug!(room=?room.room, updates=1, ms=%elapsed_ms, "applied reassembled updates"); + let apply_result = if skip_apply { + Ok(()) + } else { + match crdt { + CrdtType::Loro + | CrdtType::LoroEphemeralStore + | CrdtType::LoroEphemeralStorePersisted => { + let start = std::time::Instant::now(); + let res = h.apply_updates(&room, &[buf.clone()]); + let elapsed_ms = start.elapsed().as_millis(); + if res.is_ok() { + debug!(room=?room.room, updates=1, ms=%elapsed_ms, "applied reassembled updates"); + } + res } - res - } - CrdtType::Elo => { - // Apply as indexing-only - h.apply_updates(&room, &[buf.clone()]) + CrdtType::Elo => { + // Apply as indexing-only + h.apply_updates(&room, &[buf.clone()]) + } + _ => Ok(()), } - _ => Ok(()), }; if apply_result.is_ok() { @@ -1425,13 +1465,8 @@ where } let mut h = hub.lock().await; - if updates.iter().all(|u| u.is_empty()) { - send_ack(&tx, crdt, &room.room, batch_id, UpdateStatusCode::Ok); - continue; - } - - let mut hook_result = UpdateStatusCode::Ok; - if let Some(hook) = h.config.on_update.clone() { + let mut skip_apply = false; + if let Some(update_hook) = h.config.on_update.clone() { let ctx = h.docs.get(&room).and_then(|s| s.ctx.clone()); let doc = h.docs.get(&room).and_then(|s| s.doc.get_loro_doc()); drop(h); @@ -1444,32 +1479,36 @@ where doc, ctx, }; - hook_result = (hook)(args).await; + let mut update_hook_result = (update_hook)(args).await; h = hub.lock().await; + skip_apply = h.process_update_hook_result(&room, &mut update_hook_result); + if update_hook_result.status != UpdateStatusCode::Ok { + send_ack(&tx, crdt, &room.room, batch_id, update_hook_result.status); + continue; + } } - if hook_result != UpdateStatusCode::Ok { - send_ack(&tx, crdt, &room.room, batch_id, hook_result); - continue; - } - - let apply_result = match crdt { - CrdtType::Loro - | CrdtType::LoroEphemeralStore - | CrdtType::LoroEphemeralStorePersisted => { - let start = std::time::Instant::now(); - let res = h.apply_updates(&room, &updates); - let elapsed_ms = start.elapsed().as_millis(); - if res.is_ok() { - debug!(room=?room.room, updates=%updates.len(), ms=%elapsed_ms, "applied and broadcast updates"); + let apply_result = if skip_apply { + Ok(()) + } else { + match crdt { + CrdtType::Loro + | CrdtType::LoroEphemeralStore + | CrdtType::LoroEphemeralStorePersisted => { + let start = std::time::Instant::now(); + let res = h.apply_updates(&room, &updates); + let elapsed_ms = start.elapsed().as_millis(); + if res.is_ok() { + debug!(room=?room.room, updates=%updates.len(), ms=%elapsed_ms, "applied and broadcast updates"); + } + res } - res - } - CrdtType::Elo => { - // Index headers only; payload remains opaque to server. - h.apply_updates(&room, &updates) + CrdtType::Elo => { + // Index headers only; payload remains opaque to server. + h.apply_updates(&room, &updates) + } + _ => Ok(()), } - _ => Ok(()), }; if apply_result.is_ok() { diff --git a/rust/loro-websocket-server/tests/update_hook.rs b/rust/loro-websocket-server/tests/update_hook.rs index 1370ab9..40aa7b2 100644 --- a/rust/loro-websocket-server/tests/update_hook.rs +++ b/rust/loro-websocket-server/tests/update_hook.rs @@ -1,9 +1,10 @@ +use loro as loro_crdt; use loro_websocket_client::Client; use loro_websocket_server as server; use server::protocol::{CrdtType, ProtocolMessage, UpdateStatusCode}; use std::sync::Arc; use tokio::sync::{Mutex, Notify}; -use std::time::Duration; +use tokio::time::{timeout, Duration}; type Cfg = server::ServerConfig<()>; @@ -52,7 +53,11 @@ async fn on_update_hook_called() { updates_len: updates.len(), }); notify.notify_waiters(); - UpdateStatusCode::Ok + server::UpdatedDoc { + status: UpdateStatusCode::Ok, + ctx: None, + doc: None, + } }) })), // Use handshake auth to ensure workspace_id is captured @@ -124,10 +129,15 @@ async fn on_update_hook_can_reject() { let cfg: Cfg = server::ServerConfig { on_update: Some(Arc::new(move |args: server::UpdateArgs<()>| { Box::pin(async move { - if args.room == "rejected" { + let status = if args.room == "rejected" { UpdateStatusCode::PermissionDenied } else { UpdateStatusCode::Ok + }; + server::UpdatedDoc { + status, + ctx: None, + doc: None, } }) })), @@ -230,104 +240,223 @@ async fn on_update_hook_can_reject() { } #[tokio::test(flavor = "current_thread")] -async fn on_update_hook_not_called_for_empty_update() { +async fn on_update_hook_persists_ctx_between_calls() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind tcp listener"); let addr = listener.local_addr().expect("local addr"); - let update_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen_ctx: Arc>>> = Arc::new(Mutex::new(Vec::new())); let notify = Arc::new(Notify::new()); - let update_calls_cfg = update_calls.clone(); - let notify_cfg = notify.clone(); - - let server_task = tokio::spawn(async move { - let cfg: Cfg = server::ServerConfig { - on_update: Some(Arc::new(move |args: server::UpdateArgs<()>| { - let update_calls = update_calls_cfg.clone(); - let notify = notify_cfg.clone(); - Box::pin(async move { - let server::UpdateArgs { - workspace, - room, - crdt, - conn_id, - updates, - doc: _, - ctx: _, - } = args; - update_calls.lock().await.push(UpdateRecord { - workspace, - room, - crdt, - conn_id, - updates_len: updates.len(), - }); - notify.notify_waiters(); - UpdateStatusCode::Ok - }) - })), - handshake_auth: Some(Arc::new(|_| true)), - ..Default::default() - }; - server::serve_incoming_with_config(listener, cfg) - .await - .unwrap(); + let server_task = tokio::spawn({ + let seen_ctx = seen_ctx.clone(); + let notify = notify.clone(); + async move { + let cfg: server::ServerConfig = server::ServerConfig { + on_update: Some(Arc::new(move |args: server::UpdateArgs| { + let seen_ctx = seen_ctx.clone(); + let notify = notify.clone(); + Box::pin(async move { + { + let mut guard = seen_ctx.lock().await; + guard.push(args.ctx.clone()); + if guard.len() >= 2 { + notify.notify_waiters(); + } + } + server::UpdatedDoc { + status: UpdateStatusCode::Ok, + ctx: Some("persisted".to_string()), + doc: None, + } + }) + })), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + } }); - // Connect client - let url = format!("ws://{}/my-workspace", addr); + let url = format!("ws://{}/ctx", addr); let mut client = Client::connect(&url).await.expect("connect"); - - // Join room client .send(&ProtocolMessage::JoinRequest { crdt: CrdtType::Loro, - room_id: "room1".to_string(), + room_id: "room-ctx".to_string(), auth: vec![], version: vec![], }) .await .expect("send join"); - // Wait for join response - match client.next().await.expect("recv") { + match client.next().await.expect("join response") { Some(ProtocolMessage::JoinResponseOk { .. }) => {} - msg => panic!("unexpected msg: {:?}", msg), + other => panic!("unexpected join response: {:?}", other), } - - // Consume initial snapshot - match client.next().await.expect("recv") { + match client.next().await.expect("snapshot") { Some(ProtocolMessage::DocUpdate { .. }) => {} - msg => panic!("unexpected msg: {:?}", msg), + other => panic!("expected initial snapshot, got {:?}", other), } - // Send EMPTY update + let first_batch = server::protocol::BatchId([3; 8]); client .send(&ProtocolMessage::DocUpdate { crdt: CrdtType::Loro, - room_id: "room1".to_string(), - updates: vec![vec![]], // Empty update - batch_id: server::protocol::BatchId([1; 8]), + room_id: "room-ctx".to_string(), + updates: vec![vec![1]], + batch_id: first_batch, }) .await - .expect("send update"); - - // Wait for Ack - match client.next().await.expect("recv") { + .expect("send first update"); + match client.next().await.expect("first ack") { Some(ProtocolMessage::Ack { ref_id, status, .. }) => { - assert_eq!(ref_id, server::protocol::BatchId([1; 8])); - assert_eq!(status, UpdateStatusCode::Ok); + assert_eq!(ref_id, first_batch); + assert_eq!(status, UpdateStatusCode::Ok); } - msg => panic!("unexpected msg: {:?}", msg), + other => panic!("expected ack, got {:?}", other), } - // Verify hook was NOT called - // We wait a bit to be sure - tokio::time::sleep(Duration::from_millis(100)).await; - assert!(update_calls.lock().await.is_empty()); + let second_batch = server::protocol::BatchId([4; 8]); + client + .send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "room-ctx".to_string(), + updates: vec![vec![2]], + batch_id: second_batch, + }) + .await + .expect("send second update"); + match client.next().await.expect("second ack") { + Some(ProtocolMessage::Ack { ref_id, status, .. }) => { + assert_eq!(ref_id, second_batch); + assert_eq!(status, UpdateStatusCode::Ok); + } + other => panic!("expected ack 2, got {:?}", other), + } + let ctxs = timeout(Duration::from_secs(5), async { + loop { + { + let guard = seen_ctx.lock().await; + if guard.len() >= 2 { + return guard.clone(); + } + } + notify.notified().await; + } + }) + .await + .expect("timed out waiting for second hook invocation"); + assert_eq!(ctxs.len(), 2, "hook should run twice"); + assert!(ctxs[0].is_none(), "first call should have no ctx"); + assert_eq!(ctxs[1].as_deref(), Some("persisted")); server_task.abort(); } +#[tokio::test(flavor = "current_thread")] +async fn on_update_hook_can_supply_doc() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind tcp listener"); + let addr = listener.local_addr().expect("local addr"); + + let notify = Arc::new(Notify::new()); + + let server_task = tokio::spawn({ + let notify = notify.clone(); + async move { + let cfg: Cfg = server::ServerConfig { + on_update: Some(Arc::new(move |_args: server::UpdateArgs<()>| { + let notify = notify.clone(); + Box::pin(async move { + let doc = { + let doc = loro_crdt::LoroDoc::new(); + let text = doc.get_text("shared"); + text.insert(0, "from-hook").unwrap(); + doc + }; + notify.notify_waiters(); + server::UpdatedDoc { + status: UpdateStatusCode::Ok, + ctx: None, + doc: Some(doc), + } + }) + })), + ..Default::default() + }; + server::serve_incoming_with_config(listener, cfg) + .await + .unwrap(); + } + }); + + let url = format!("ws://{}/doc", addr); + let mut c1 = Client::connect(&url).await.expect("c1 connect"); + c1.send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "room-doc".to_string(), + auth: vec![], + version: vec![], + }) + .await + .expect("join doc room"); + match c1.next().await.expect("join response") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {} + other => panic!("unexpected join response: {:?}", other), + } + match c1.next().await.expect("initial snapshot") { + Some(ProtocolMessage::DocUpdate { .. }) => {} + other => panic!("expected initial snapshot, got {:?}", other), + } + + let notify_wait = notify.notified(); + let batch_id = server::protocol::BatchId([5; 8]); + c1.send(&ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: "room-doc".to_string(), + updates: vec![vec![9]], + batch_id, + }) + .await + .expect("send update to trigger hook doc"); + match c1.next().await.expect("ack") { + Some(ProtocolMessage::Ack { ref_id, status, .. }) => { + assert_eq!(ref_id, batch_id); + assert_eq!(status, UpdateStatusCode::Ok); + } + other => panic!("expected ack, got {:?}", other), + } + + notify_wait.await; + drop(c1); + + let mut c2 = Client::connect(&url).await.expect("c2 connect"); + c2.send(&ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: "room-doc".to_string(), + auth: vec![], + version: vec![], + }) + .await + .expect("join after hook doc"); + match c2.next().await.expect("join response") { + Some(ProtocolMessage::JoinResponseOk { .. }) => {} + other => panic!("unexpected join response: {:?}", other), + } + + let snapshot = match c2.next().await.expect("snapshot after hook doc") { + Some(ProtocolMessage::DocUpdate { updates, .. }) => updates, + other => panic!("expected doc snapshot, got {:?}", other), + }; + let doc = loro_crdt::LoroDoc::new(); + for data in snapshot { + let _ = doc.import(&data); + } + assert_eq!(doc.get_text("shared").to_string(), "from-hook"); + + server_task.abort(); +} From 5c01b68c62e25e144819d80f03e3b8612a623cdd Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Fri, 23 Jan 2026 11:09:30 +0100 Subject: [PATCH 10/15] Support server init with external registry --- rust/loro-websocket-server/src/lib.rs | 121 +++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 66c4ab5..04df87a 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -858,7 +858,31 @@ fn send_ack( } } -struct HubRegistry { +/// Information about a room's current state. +#[derive(Clone, Debug)] +pub struct RoomInfo { + /// The CRDT type of the room. + pub crdt: CrdtType, + /// The room identifier. + pub room_id: String, + /// Number of connected subscribers. + pub subscriber_count: usize, +} + +/// Information about a workspace's current state. +#[derive(Clone, Debug)] +pub struct WorkspaceInfo { + /// The workspace identifier. + pub workspace_id: String, + /// Information about each active room. + pub rooms: Vec, +} + +/// Registry that manages all workspace hubs. +/// +/// This can be shared between your WebSocket server and HTTP endpoints +/// to expose information about connected clients and rooms. +pub struct HubRegistry { config: ServerConfig, hubs: tokio::sync::Mutex>>>>, } @@ -867,13 +891,63 @@ impl HubRegistry where DocCtx: Clone + Send + Sync + 'static, { - fn new(config: ServerConfig) -> Self { + /// Create a new hub registry with the given configuration. + pub fn new(config: ServerConfig) -> Self { Self { config, hubs: tokio::sync::Mutex::new(HashMap::new()), } } + /// List all active workspace IDs. + pub async fn list_workspaces(&self) -> Vec { + let map = self.hubs.lock().await; + map.keys().cloned().collect() + } + + /// Get information about a specific workspace. + pub async fn get_workspace_info(&self, workspace: &str) -> Option { + let map = self.hubs.lock().await; + let hub = map.get(workspace)?; + let h = hub.lock().await; + let rooms = h + .subs + .iter() + .map(|(k, subs)| RoomInfo { + crdt: k.crdt, + room_id: k.room.clone(), + subscriber_count: subs.len(), + }) + .collect(); + Some(WorkspaceInfo { + workspace_id: workspace.to_string(), + rooms, + }) + } + + /// Get information about all workspaces. + pub async fn get_all_workspace_info(&self) -> Vec { + let map = self.hubs.lock().await; + let mut result = Vec::with_capacity(map.len()); + for (workspace_id, hub) in map.iter() { + let h = hub.lock().await; + let rooms = h + .subs + .iter() + .map(|(k, subs)| RoomInfo { + crdt: k.crdt, + room_id: k.room.clone(), + subscriber_count: subs.len(), + }) + .collect(); + result.push(WorkspaceInfo { + workspace_id: workspace_id.clone(), + rooms, + }); + } + result + } + async fn get_or_create(&self, workspace: &str) -> Arc>> { let mut map = self.hubs.lock().await; if let Some(h) = map.get(workspace) { @@ -953,8 +1027,49 @@ pub async fn serve_incoming_with_config( where DocCtx: Clone + Send + Sync + 'static, { - let registry = Arc::new(HubRegistry::new(config.clone())); + let registry = Arc::new(HubRegistry::new(config)); + serve_incoming_with_registry(listener, registry).await +} +/// Serve a pre-bound listener using an existing registry. +/// +/// This allows you to share the registry with other parts of your application, +/// for example to expose HTTP endpoints that query the registry state. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), Box> { +/// # let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?; +/// # rt.block_on(async move { +/// use std::sync::Arc; +/// use loro_websocket_server::{HubRegistry, ServerConfig, serve_incoming_with_registry}; +/// use tokio::net::TcpListener; +/// +/// let config = ServerConfig::<()>::default(); +/// let registry = Arc::new(HubRegistry::new(config)); +/// +/// // Clone registry for use in HTTP endpoints +/// let registry_for_http = registry.clone(); +/// +/// // Spawn HTTP server that uses registry_for_http +/// tokio::spawn(async move { +/// // Your HTTP server code here, e.g.: +/// // let workspaces = registry_for_http.list_workspaces().await; +/// }); +/// +/// // Start WebSocket server +/// let listener = TcpListener::bind("127.0.0.1:9000").await?; +/// serve_incoming_with_registry(listener, registry).await +/// # }) +/// # } +/// ``` +pub async fn serve_incoming_with_registry( + listener: TcpListener, + registry: Arc>, +) -> Result<(), Box> +where + DocCtx: Clone + Send + Sync + 'static, +{ loop { match listener.accept().await { Ok((stream, peer)) => { From 74ef22da3baacd7a520d790a9fcf793db145201a Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Fri, 23 Jan 2026 11:14:12 +0100 Subject: [PATCH 11/15] Removed superflous comment --- rust/loro-websocket-server/src/lib.rs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 04df87a..8fbc639 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -1036,33 +1036,6 @@ where /// This allows you to share the registry with other parts of your application, /// for example to expose HTTP endpoints that query the registry state. /// -/// # Example -/// ```no_run -/// # fn main() -> Result<(), Box> { -/// # let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?; -/// # rt.block_on(async move { -/// use std::sync::Arc; -/// use loro_websocket_server::{HubRegistry, ServerConfig, serve_incoming_with_registry}; -/// use tokio::net::TcpListener; -/// -/// let config = ServerConfig::<()>::default(); -/// let registry = Arc::new(HubRegistry::new(config)); -/// -/// // Clone registry for use in HTTP endpoints -/// let registry_for_http = registry.clone(); -/// -/// // Spawn HTTP server that uses registry_for_http -/// tokio::spawn(async move { -/// // Your HTTP server code here, e.g.: -/// // let workspaces = registry_for_http.list_workspaces().await; -/// }); -/// -/// // Start WebSocket server -/// let listener = TcpListener::bind("127.0.0.1:9000").await?; -/// serve_incoming_with_registry(listener, registry).await -/// # }) -/// # } -/// ``` pub async fn serve_incoming_with_registry( listener: TcpListener, registry: Arc>, From 36e496660f22023efcb5765723f196e1d3752d9a Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Fri, 23 Jan 2026 12:26:05 +0100 Subject: [PATCH 12/15] Support server lorodoc updates without need for websocket --- rust/loro-websocket-server/src/lib.rs | 219 ++++++++++++++++---------- 1 file changed, 135 insertions(+), 84 deletions(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 8fbc639..6f69049 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -52,10 +52,13 @@ use tracing::{debug, error, info, warn}; const MAX_FRAGMENTS: u64 = 4096; // hard cap on number of fragments per batch const MAX_BATCH_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB per batch +/// Key identifying a room by its CRDT type and room ID. #[derive(Clone, Debug, PartialEq, Eq)] -struct RoomKey { - crdt: CrdtType, - room: String, +pub struct RoomKey { + /// The CRDT type of the room. + pub crdt: CrdtType, + /// The room identifier. + pub room: String, } impl Hash for RoomKey { fn hash(&self, state: &mut H) { @@ -183,8 +186,12 @@ pub struct ServerConfig { pub on_close_connection: Option, } -// CRDT document abstraction to reduce match-based branching -trait CrdtDoc: Send { +/// CRDT document abstraction to reduce match-based branching. +/// +/// This trait is implemented by different document types (Loro, Ephemeral, Elo). +/// You can use it to query document state through the `RoomDocState.doc` field. +pub trait CrdtDoc: Send { + /// Get the current version vector as bytes. fn get_version(&self) -> Vec { Vec::new() } @@ -515,21 +522,27 @@ impl Default for ServerConfig { } } -struct RoomDocState { - doc: Box, - dirty: bool, - ctx: Option, +/// State of a document in a room. +pub struct RoomDocState { + /// The underlying CRDT document (trait object). + pub doc: Box, + /// Whether the document has unsaved changes. + pub dirty: bool, + /// Optional application-specific context. + pub ctx: Option, } -struct Hub { - // room -> vec of (conn_id, sender) - subs: HashMap>, - // room -> document state (Loro persistent, Ephemeral in-memory, Elo index) - docs: HashMap>, +/// A hub managing subscriptions and documents for a single workspace. +pub struct Hub { + /// Room -> vec of (conn_id, sender). Use this to inspect connected clients per room. + pub subs: HashMap>, + /// Room -> document state. Use this to inspect loaded documents. + pub docs: HashMap>, config: ServerConfig, - // (conn_id, room) -> permission - perms: HashMap<(u64, RoomKey), Permission>, - workspace: String, + /// (conn_id, room) -> permission. Use this to inspect client permissions. + pub perms: HashMap<(u64, RoomKey), Permission>, + /// The workspace identifier. + pub workspace: String, // Fragment reassembly state: per room + batch id fragments: HashMap<(RoomKey, protocol::BatchId), FragmentBatch>, } @@ -738,6 +751,94 @@ where } } + /// Apply updates to a room's document and broadcast to all subscribers. + /// + /// This is useful for server-initiated updates (e.g., from HTTP endpoints). + /// Calls the `on_update` hook if configured, and respects its result. + /// Returns the number of subscribers the update was sent to, or an error. + /// + /// # Example + /// ```ignore + /// let hubs = registry.hubs().lock().await; + /// if let Some(hub) = hubs.get("my-workspace") { + /// let mut h = hub.lock().await; + /// let room = RoomKey { crdt: CrdtType::Loro, room: "my-room".into() }; + /// match h.push_update(&room, vec![update_bytes]).await { + /// Ok(n) => println!("Broadcasted to {} subscribers", n), + /// Err(e) => eprintln!("Failed: {}", e), + /// } + /// } + /// ``` + pub async fn push_update(&mut self, room: &RoomKey, updates: Vec>) -> Result { + // Check room exists + if !self.docs.contains_key(room) { + return Err("room not found".into()); + } + + // Call on_update hook if configured + let mut skip_apply = false; + if let Some(update_hook) = self.config.on_update.clone() { + let ctx = self.docs.get(room).and_then(|s| s.ctx.clone()); + let doc = self.docs.get(room).and_then(|s| s.doc.get_loro_doc()); + let args = UpdateArgs { + workspace: self.workspace.clone(), + room: room.room.clone(), + crdt: room.crdt, + conn_id: 0, // Server-initiated, no connection + updates: updates.clone(), + doc, + ctx, + }; + let mut result = (update_hook)(args).await; + + if result.status != UpdateStatusCode::Ok { + return Err(format!("on_update hook rejected: {:?}", result.status)); + } + + skip_apply = self.process_update_hook_result(room, &mut result); + } + + // Apply to doc (unless hook already did) + if !skip_apply { + if let Some(state) = self.docs.get_mut(room) { + state.doc.apply_updates(&updates)?; + if state.doc.should_persist() { + state.dirty = true; + } + } + } + + // Broadcast to all subscribers + let batch_id = next_batch_id(); + let msg = ProtocolMessage::DocUpdate { + crdt: room.crdt, + room_id: room.room.clone(), + updates, + batch_id, + }; + let encoded = match loro_protocol::encode(&msg) { + Ok(b) => b, + Err(e) => return Err(format!("encode failed: {:?}", e)), + }; + + let mut sent = 0usize; + if let Some(list) = self.subs.get_mut(room) { + let mut dead: HashSet = HashSet::new(); + for (id, tx) in list.iter() { + if tx.send(Message::Binary(encoded.clone().into())).is_err() { + dead.insert(*id); + } else { + sent += 1; + } + } + if !dead.is_empty() { + list.retain(|(id, _)| !dead.contains(id)); + } + } + + Ok(sent) + } + fn process_update_hook_result( &mut self, room: &RoomKey, @@ -858,26 +959,6 @@ fn send_ack( } } -/// Information about a room's current state. -#[derive(Clone, Debug)] -pub struct RoomInfo { - /// The CRDT type of the room. - pub crdt: CrdtType, - /// The room identifier. - pub room_id: String, - /// Number of connected subscribers. - pub subscriber_count: usize, -} - -/// Information about a workspace's current state. -#[derive(Clone, Debug)] -pub struct WorkspaceInfo { - /// The workspace identifier. - pub workspace_id: String, - /// Information about each active room. - pub rooms: Vec, -} - /// Registry that manages all workspace hubs. /// /// This can be shared between your WebSocket server and HTTP endpoints @@ -899,53 +980,23 @@ where } } - /// List all active workspace IDs. - pub async fn list_workspaces(&self) -> Vec { - let map = self.hubs.lock().await; - map.keys().cloned().collect() - } - - /// Get information about a specific workspace. - pub async fn get_workspace_info(&self, workspace: &str) -> Option { - let map = self.hubs.lock().await; - let hub = map.get(workspace)?; - let h = hub.lock().await; - let rooms = h - .subs - .iter() - .map(|(k, subs)| RoomInfo { - crdt: k.crdt, - room_id: k.room.clone(), - subscriber_count: subs.len(), - }) - .collect(); - Some(WorkspaceInfo { - workspace_id: workspace.to_string(), - rooms, - }) - } - - /// Get information about all workspaces. - pub async fn get_all_workspace_info(&self) -> Vec { - let map = self.hubs.lock().await; - let mut result = Vec::with_capacity(map.len()); - for (workspace_id, hub) in map.iter() { - let h = hub.lock().await; - let rooms = h - .subs - .iter() - .map(|(k, subs)| RoomInfo { - crdt: k.crdt, - room_id: k.room.clone(), - subscriber_count: subs.len(), - }) - .collect(); - result.push(WorkspaceInfo { - workspace_id: workspace_id.clone(), - rooms, - }); - } - result + /// Access the underlying hubs map. + /// + /// Returns a reference to the mutex-protected map of workspace ID -> Hub. + /// Use this to implement your own inspection logic in HTTP endpoints. + /// + /// # Example + /// ```ignore + /// let hubs = registry.hubs().lock().await; + /// for (workspace_id, hub) in hubs.iter() { + /// let h = hub.lock().await; + /// for (room_key, subscribers) in h.subs.iter() { + /// println!("Room {} has {} subscribers", room_key.room, subscribers.len()); + /// } + /// } + /// ``` + pub fn hubs(&self) -> &tokio::sync::Mutex>>>> { + &self.hubs } async fn get_or_create(&self, workspace: &str) -> Arc>> { From 5f1ae749d4822b283f330b63e32a80bb7746f78a Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Sat, 24 Jan 2026 16:26:46 +0100 Subject: [PATCH 13/15] Added support for opening and closing rooms without socket connection. --- rust/loro-websocket-server/src/lib.rs | 245 +++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 1 deletion(-) diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 6f69049..13043e5 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -44,7 +44,7 @@ use loro::awareness::EphemeralStore; use loro::{ExportMode, LoroDoc}; pub use loro_protocol as protocol; use protocol::{ - try_decode, CrdtType, JoinErrorCode, Permission, ProtocolMessage, UpdateStatusCode, + try_decode, CrdtType, JoinErrorCode, Permission, ProtocolMessage, RoomErrorCode, UpdateStatusCode, }; use tracing::{debug, error, info, warn}; @@ -999,6 +999,249 @@ where &self.hubs } + /// Open a room, creating the hub and loading the document if needed. + /// + /// This is idempotent - if the room already exists, nothing happens. + /// Useful for pre-creating rooms before any WebSocket clients connect. + /// + /// # Example + /// ```ignore + /// // Pre-create a room so it's ready when clients connect + /// registry.open_room("my-workspace", CrdtType::Loro, "my-room").await; + /// ``` + pub async fn open_room(&self, workspace: &str, crdt: CrdtType, room_id: &str) { + let hub = self.get_or_create(workspace).await; + let mut h = hub.lock().await; + let room = RoomKey { + crdt, + room: room_id.to_string(), + }; + h.ensure_room_loaded(&room).await; + } + + /// Close a room if it has no subscribers (or forcefully). + /// + /// Returns `true` if the room was closed, `false` if it has active subscribers + /// (when `force` is false) or didn't exist. Saves dirty documents before closing + /// if `on_save_document` is configured. + /// + /// When `force` is true, the room is closed even if there are active subscribers. + /// Their sender channels will be dropped (they won't receive further updates). + /// + /// # Example + /// ```ignore + /// // Close only if no subscribers + /// if registry.close_room("my-workspace", CrdtType::Loro, "my-room", false).await { + /// println!("Room closed"); + /// } + /// + /// // Force close regardless of subscribers + /// registry.close_room("my-workspace", CrdtType::Loro, "my-room", true).await; + /// ``` + pub async fn close_room(&self, workspace: &str, crdt: CrdtType, room_id: &str, force: bool) -> bool { + let hubs = self.hubs.lock().await; + let Some(hub) = hubs.get(workspace) else { + return false; + }; + let mut h = hub.lock().await; + let room = RoomKey { + crdt, + room: room_id.to_string(), + }; + + // Check if room has subscribers (unless forcing) + if !force { + if let Some(subs) = h.subs.get(&room) { + if !subs.is_empty() { + return false; + } + } + } + + // Notify subscribers before closing + if let Some(subs) = h.subs.get(&room) { + if !subs.is_empty() { + let err_msg = ProtocolMessage::RoomError { + crdt, + room_id: room_id.to_string(), + code: RoomErrorCode::Evicted, + message: "Room closed by server".to_string(), + }; + if let Ok(bytes) = loro_protocol::encode(&err_msg) { + for (_, tx) in subs.iter() { + let _ = tx.send(Message::Binary(bytes.clone().into())); + } + } + } + } + + // Save dirty document before closing if on_save_document is configured + if let Some(saver) = &self.config.on_save_document { + if let Some(state) = h.docs.get_mut(&room) { + if state.dirty && state.doc.should_persist() { + if let Some(snapshot) = state.doc.export_snapshot() { + let args = SaveDocArgs { + workspace: workspace.to_string(), + room: room_id.to_string(), + crdt, + data: snapshot, + ctx: state.ctx.clone(), + }; + match (saver)(args).await { + Ok(()) => { + state.dirty = false; + debug!(workspace=%workspace, room=%room_id, "saved room before closing"); + } + Err(e) => { + warn!(workspace=%workspace, room=%room_id, %e, "failed to save room before closing"); + } + } + } + } + } + } + + // Remove room state + h.docs.remove(&room); + h.subs.remove(&room); + h.perms.retain(|(_, k), _| k != &room); + h.fragments.retain(|(k, _), _| k != &room); + true + } + + /// Save a room's document if it has unsaved changes. + /// + /// Returns `Ok(true)` if the document was saved, `Ok(false)` if there was + /// nothing to save (not dirty or doesn't support persistence), or an error + /// if saving failed or `on_save_document` is not configured. + /// + /// # Example + /// ```ignore + /// match registry.save_room("my-workspace", CrdtType::Loro, "my-room").await { + /// Ok(true) => println!("Saved"), + /// Ok(false) => println!("Nothing to save"), + /// Err(e) => eprintln!("Save failed: {}", e), + /// } + /// ``` + pub async fn save_room(&self, workspace: &str, crdt: CrdtType, room_id: &str) -> Result { + let Some(saver) = &self.config.on_save_document else { + return Err("on_save_document not configured".into()); + }; + + let hubs = self.hubs.lock().await; + let Some(hub) = hubs.get(workspace) else { + return Err("workspace not found".into()); + }; + let mut h = hub.lock().await; + let room = RoomKey { + crdt, + room: room_id.to_string(), + }; + + let Some(state) = h.docs.get_mut(&room) else { + return Err("room not found".into()); + }; + + if !state.dirty || !state.doc.should_persist() { + return Ok(false); + } + + let Some(snapshot) = state.doc.export_snapshot() else { + return Ok(false); + }; + + let args = SaveDocArgs { + workspace: workspace.to_string(), + room: room_id.to_string(), + crdt, + data: snapshot, + ctx: state.ctx.clone(), + }; + + (saver)(args).await.map_err(|e| e)?; + state.dirty = false; + debug!(workspace=%workspace, room=%room_id, "room saved"); + Ok(true) + } + + /// Close a hub (workspace) if all its rooms have no subscribers (or forcefully). + /// + /// Returns `true` if the hub was closed, `false` if any room has active + /// subscribers (when `force` is false). Saves all dirty rooms before closing + /// if `on_save_document` is configured. + /// + /// When `force` is true, the hub is closed even if rooms have active subscribers. + /// Their sender channels will be dropped (they won't receive further updates). + /// + /// Note: The hub's saver task will stop when the hub is dropped (after + /// all Arc references are released). + pub async fn close_hub(&self, workspace: &str, force: bool) -> bool { + let mut hubs = self.hubs.lock().await; + let Some(hub) = hubs.get(workspace) else { + return false; + }; + + let mut h = hub.lock().await; + // Check if any room has subscribers (unless forcing) + if !force { + for subs in h.subs.values() { + if !subs.is_empty() { + return false; + } + } + } + + // Notify all subscribers in all rooms before closing + for (room, subs) in h.subs.iter() { + if !subs.is_empty() { + let err_msg = ProtocolMessage::RoomError { + crdt: room.crdt, + room_id: room.room.clone(), + code: RoomErrorCode::Evicted, + message: "Hub closed by server".to_string(), + }; + if let Ok(bytes) = loro_protocol::encode(&err_msg) { + for (_, tx) in subs.iter() { + let _ = tx.send(Message::Binary(bytes.clone().into())); + } + } + } + } + + // Save all dirty rooms before closing if on_save_document is configured + if let Some(saver) = &self.config.on_save_document { + let rooms: Vec = h.docs.keys().cloned().collect(); + for room in rooms { + if let Some(state) = h.docs.get_mut(&room) { + if state.dirty && state.doc.should_persist() { + if let Some(snapshot) = state.doc.export_snapshot() { + let args = SaveDocArgs { + workspace: workspace.to_string(), + room: room.room.clone(), + crdt: room.crdt, + data: snapshot, + ctx: state.ctx.clone(), + }; + match (saver)(args).await { + Ok(()) => { + state.dirty = false; + debug!(workspace=%workspace, room=%room.room, "saved room before closing hub"); + } + Err(e) => { + warn!(workspace=%workspace, room=%room.room, %e, "failed to save room before closing hub"); + } + } + } + } + } + } + } + drop(h); + + hubs.remove(workspace); + true + } + async fn get_or_create(&self, workspace: &str) -> Arc>> { let mut map = self.hubs.lock().await; if let Some(h) = map.get(workspace) { From 3f850669ca6ef297942059d818d819aae8e407c4 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Wed, 4 Feb 2026 01:15:24 +0100 Subject: [PATCH 14/15] Support editing the document from the server side. --- rust/loro-websocket-server/src/lib.rs | 96 +++++++++++++++++++ .../tests/edit_loro_doc.rs | 92 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 rust/loro-websocket-server/tests/edit_loro_doc.rs diff --git a/rust/loro-websocket-server/src/lib.rs b/rust/loro-websocket-server/src/lib.rs index 13043e5..1afac7e 100644 --- a/rust/loro-websocket-server/src/lib.rs +++ b/rust/loro-websocket-server/src/lib.rs @@ -220,6 +220,9 @@ pub trait CrdtDoc: Send { fn set_loro_doc(&mut self, _doc: LoroDoc) -> bool { false } + fn as_loro_doc_mut(&mut self) -> Option<&mut LoroDoc> { + None + } } struct LoroRoomDoc { @@ -255,6 +258,9 @@ impl CrdtDoc for LoroRoomDoc { self.doc = doc; true } + fn as_loro_doc_mut(&mut self) -> Option<&mut LoroDoc> { + Some(&mut self.doc) + } } struct EphemeralRoomDoc { @@ -1019,6 +1025,96 @@ where h.ensure_room_loaded(&room).await; } + /// Edit a Loro document directly on the server and notify subscribers. + /// + /// This method loads the room (if needed), runs the provided callback with a + /// reference to the underlying `LoroDoc`, exports a snapshot, and broadcasts + /// it to all subscribers. The callback should perform mutations and call + /// `commit()` when done so the snapshot captures the new state. + /// + /// After the edit, if the room has no subscribers, it will be saved (if dirty) + /// and closed to avoid leaving orphan rooms. If `force_close` is true, the room + /// will be closed even if it has subscribers. + pub async fn edit_loro_doc( + &self, + workspace: &str, + room_id: &str, + edit: F, + force_close: bool, + ) -> Result<(), String> + where + F: FnOnce(&LoroDoc) -> Result<(), String> + Send, + { + let hub = self.get_or_create(workspace).await; + let room = RoomKey { + crdt: CrdtType::Loro, + room: room_id.to_string(), + }; + + // Do the work, capturing whether we should close and the result + let (result, should_close) = { + let mut h = hub.lock().await; + h.ensure_room_loaded(&room).await; + + let Some(state) = h.docs.get_mut(&room) else { + // Room not found after ensure_room_loaded - shouldn't happen + return Err("room not found".into()); + }; + + let edit_result = { + let Some(doc) = state.doc.as_loro_doc_mut() else { + return Err("room is not a Loro document".into()); + }; + edit(doc) + }; + + if let Err(e) = edit_result { + let has_subs = h.subs.get(&room).map(|v| !v.is_empty()).unwrap_or(false); + (Err(e), force_close || !has_subs) + } else { + let state = h.docs.get_mut(&room).unwrap(); // safe: we just checked above + if state.doc.should_persist() { + state.dirty = true; + } + + let snapshot = state.doc.export_snapshot(); + let has_subs = h.subs.get(&room).map(|v| !v.is_empty()).unwrap_or(false); + + if let Some(snap) = snapshot { + if !snap.is_empty() { + let batch_id = next_batch_id(); + let msg = ProtocolMessage::DocUpdate { + crdt: CrdtType::Loro, + room_id: room.room.clone(), + updates: vec![snap], + batch_id, + }; + match loro_protocol::encode(&msg) { + Ok(encoded) => { + h.broadcast(&room, 0, Message::Binary(encoded.into())); + (Ok(()), force_close || !has_subs) + } + Err(e) => { + (Err(format!("encode failed: {:?}", e)), force_close || !has_subs) + } + } + } else { + (Ok(()), force_close || !has_subs) + } + } else { + (Ok(()), force_close || !has_subs) + } + } + }; + + // Close room if no subscribers or force_close requested (deferred until lock released) + if should_close { + self.close_room(workspace, CrdtType::Loro, room_id, force_close).await; + } + + result + } + /// Close a room if it has no subscribers (or forcefully). /// /// Returns `true` if the room was closed, `false` if it has active subscribers diff --git a/rust/loro-websocket-server/tests/edit_loro_doc.rs b/rust/loro-websocket-server/tests/edit_loro_doc.rs new file mode 100644 index 0000000..9a31a75 --- /dev/null +++ b/rust/loro-websocket-server/tests/edit_loro_doc.rs @@ -0,0 +1,92 @@ +use loro as loro_crdt; +use loro_websocket_client::Client; +use loro_websocket_server as server; +use loro_websocket_server::protocol::{self as proto, CrdtType}; +use std::sync::Arc; + +#[tokio::test(flavor = "current_thread")] +async fn edit_loro_doc_notifies_subscribers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let addr = listener.local_addr().expect("local addr"); + let cfg: server::ServerConfig<()> = server::ServerConfig { + handshake_auth: Some(Arc::new(|_| true)), + ..Default::default() + }; + let registry: Arc> = Arc::new(server::HubRegistry::new(cfg)); + let server_registry = registry.clone(); + let server_task = tokio::spawn(async move { + server::serve_incoming_with_registry(listener, server_registry) + .await + .expect("server exited"); + }); + + let url = format!("ws://{}/workspace", addr); + let mut client = Client::connect(&url).await.expect("client connect"); + let room = "edit-room".to_string(); + let join = proto::ProtocolMessage::JoinRequest { + crdt: CrdtType::Loro, + room_id: room.clone(), + auth: Vec::new(), + version: Vec::new(), + }; + client.send(&join).await.expect("send join"); + + // Drain the join response before editing. + loop { + match client.next().await.expect("next message") { + Some(proto::ProtocolMessage::JoinResponseOk { .. }) => break, + Some(_) => continue, + None => panic!("connection closed while joining"), + } + } + + // Verify that the client is registered as a subscriber. + let hub_arc = { + let hubs_guard = registry.hubs().lock().await; + hubs_guard + .get("workspace") + .cloned() + .expect("workspace hub not found") + }; + let subscriber_count = { + let hub_guard = hub_arc.lock().await; + let key = server::RoomKey { + crdt: CrdtType::Loro, + room: room.clone(), + }; + hub_guard.subs.get(&key).map(|v| v.len()).unwrap_or(0) + }; + assert_eq!(subscriber_count, 1, "expected the client to be subscribed"); + + registry + .edit_loro_doc("workspace", &room, |doc| { + let text = doc.get_text("text"); + text.insert(0, "from-server").unwrap(); + doc.commit(); + Ok(()) + }, false) // force_close = false, room will stay open since it has a subscriber + .await + .expect("edit succeeded"); + + let mut got_update = false; + for _ in 0..4 { + if let Some(proto::ProtocolMessage::DocUpdate { updates, .. }) = + client.next().await.expect("next message after edit") + { + let doc = loro_crdt::LoroDoc::new(); + for data in updates { + let _ = doc.import(&data); + } + if doc.get_text("text").to_string() == "from-server" { + got_update = true; + break; + } + } + } + assert!(got_update, "client did not receive server edit"); + + drop(client); + server_task.abort(); +} From c12d734251a060dd91977ac9f38cf4576792d421 Mon Sep 17 00:00:00 2001 From: Karsten Daemen Date: Wed, 4 Feb 2026 18:24:48 +0100 Subject: [PATCH 15/15] Emitting room error status before cleanup room on js client --- packages/loro-websocket/src/client/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/loro-websocket/src/client/index.ts b/packages/loro-websocket/src/client/index.ts index de9f11e..a60b9bc 100644 --- a/packages/loro-websocket/src/client/index.ts +++ b/packages/loro-websocket/src/client/index.ts @@ -717,8 +717,8 @@ export class LoroWebsocketClient { void this.sendRejoinRequest(roomId, msg.roomId, adaptor, active.room, auth); } else { // Remove local room state so client does not auto-retry unless requested - this.cleanupRoom(msg.roomId, msg.crdt); this.emitRoomStatus(roomId, RoomJoinStatus.Error); + this.cleanupRoom(msg.roomId, msg.crdt); } break; }