max / makenotwork
5 files changed,
+867 insertions,
-0 deletions
| @@ -3,6 +3,28 @@ | |||
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | 5 | [[package]] | |
| 6 | + | name = "async-stream" | |
| 7 | + | version = "0.3.6" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" | |
| 10 | + | dependencies = [ | |
| 11 | + | "async-stream-impl", | |
| 12 | + | "futures-core", | |
| 13 | + | "pin-project-lite", | |
| 14 | + | ] | |
| 15 | + | ||
| 16 | + | [[package]] | |
| 17 | + | name = "async-stream-impl" | |
| 18 | + | version = "0.3.6" | |
| 19 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 20 | + | checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" | |
| 21 | + | dependencies = [ | |
| 22 | + | "proc-macro2", | |
| 23 | + | "quote", | |
| 24 | + | "syn 2.0.119", | |
| 25 | + | ] | |
| 26 | + | ||
| 27 | + | [[package]] | |
| 6 | 28 | name = "async-trait" | |
| 7 | 29 | version = "0.1.91" | |
| 8 | 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -14,6 +36,64 @@ dependencies = [ | |||
| 14 | 36 | ] | |
| 15 | 37 | ||
| 16 | 38 | [[package]] | |
| 39 | + | name = "atomic-waker" | |
| 40 | + | version = "1.1.2" | |
| 41 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 42 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 43 | + | ||
| 44 | + | [[package]] | |
| 45 | + | name = "axum" | |
| 46 | + | version = "0.8.9" | |
| 47 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 48 | + | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | |
| 49 | + | dependencies = [ | |
| 50 | + | "axum-core", | |
| 51 | + | "bytes", | |
| 52 | + | "form_urlencoded", | |
| 53 | + | "futures-util", | |
| 54 | + | "http", | |
| 55 | + | "http-body", | |
| 56 | + | "http-body-util", | |
| 57 | + | "hyper", | |
| 58 | + | "hyper-util", | |
| 59 | + | "itoa", | |
| 60 | + | "matchit", | |
| 61 | + | "memchr", | |
| 62 | + | "mime", | |
| 63 | + | "percent-encoding", | |
| 64 | + | "pin-project-lite", | |
| 65 | + | "serde_core", | |
| 66 | + | "serde_json", | |
| 67 | + | "serde_path_to_error", | |
| 68 | + | "serde_urlencoded", | |
| 69 | + | "sync_wrapper", | |
| 70 | + | "tokio", | |
| 71 | + | "tower", | |
| 72 | + | "tower-layer", | |
| 73 | + | "tower-service", | |
| 74 | + | "tracing", | |
| 75 | + | ] | |
| 76 | + | ||
| 77 | + | [[package]] | |
| 78 | + | name = "axum-core" | |
| 79 | + | version = "0.5.6" | |
| 80 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 81 | + | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | |
| 82 | + | dependencies = [ | |
| 83 | + | "bytes", | |
| 84 | + | "futures-core", | |
| 85 | + | "http", | |
| 86 | + | "http-body", | |
| 87 | + | "http-body-util", | |
| 88 | + | "mime", | |
| 89 | + | "pin-project-lite", | |
| 90 | + | "sync_wrapper", | |
| 91 | + | "tower-layer", | |
| 92 | + | "tower-service", | |
| 93 | + | "tracing", | |
| 94 | + | ] | |
| 95 | + | ||
| 96 | + | [[package]] | |
| 17 | 97 | name = "bitflags" | |
| 18 | 98 | version = "2.13.1" | |
| 19 | 99 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -26,6 +106,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 26 | 106 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 27 | 107 | ||
| 28 | 108 | [[package]] | |
| 109 | + | name = "bytes" | |
| 110 | + | version = "1.12.1" | |
| 111 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 112 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 113 | + | ||
| 114 | + | [[package]] | |
| 29 | 115 | name = "cfg-if" | |
| 30 | 116 | version = "1.0.4" | |
| 31 | 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -52,12 +138,36 @@ dependencies = [ | |||
| 52 | 138 | ] | |
| 53 | 139 | ||
| 54 | 140 | [[package]] | |
| 141 | + | name = "form_urlencoded" | |
| 142 | + | version = "1.2.2" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 145 | + | dependencies = [ | |
| 146 | + | "percent-encoding", | |
| 147 | + | ] | |
| 148 | + | ||
| 149 | + | [[package]] | |
| 150 | + | name = "futures-channel" | |
| 151 | + | version = "0.3.33" | |
| 152 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 153 | + | checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" | |
| 154 | + | dependencies = [ | |
| 155 | + | "futures-core", | |
| 156 | + | ] | |
| 157 | + | ||
| 158 | + | [[package]] | |
| 55 | 159 | name = "futures-core" | |
| 56 | 160 | version = "0.3.33" | |
| 57 | 161 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 58 | 162 | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 59 | 163 | ||
| 60 | 164 | [[package]] | |
| 165 | + | name = "futures-sink" | |
| 166 | + | version = "0.3.33" | |
| 167 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 168 | + | checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" | |
| 169 | + | ||
| 170 | + | [[package]] | |
| 61 | 171 | name = "futures-task" | |
| 62 | 172 | version = "0.3.33" | |
| 63 | 173 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -93,6 +203,86 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 93 | 203 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | |
| 94 | 204 | ||
| 95 | 205 | [[package]] | |
| 206 | + | name = "http" | |
| 207 | + | version = "1.4.2" | |
| 208 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 209 | + | checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" | |
| 210 | + | dependencies = [ | |
| 211 | + | "bytes", | |
| 212 | + | "itoa", | |
| 213 | + | ] | |
| 214 | + | ||
| 215 | + | [[package]] | |
| 216 | + | name = "http-body" | |
| 217 | + | version = "1.1.0" | |
| 218 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 219 | + | checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" | |
| 220 | + | dependencies = [ | |
| 221 | + | "bytes", | |
| 222 | + | "http", | |
| 223 | + | ] | |
| 224 | + | ||
| 225 | + | [[package]] | |
| 226 | + | name = "http-body-util" | |
| 227 | + | version = "0.1.4" | |
| 228 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 229 | + | checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" | |
| 230 | + | dependencies = [ | |
| 231 | + | "bytes", | |
| 232 | + | "futures-core", | |
| 233 | + | "http", | |
| 234 | + | "http-body", | |
| 235 | + | "pin-project-lite", | |
| 236 | + | ] | |
| 237 | + | ||
| 238 | + | [[package]] | |
| 239 | + | name = "httparse" | |
| 240 | + | version = "1.10.1" | |
| 241 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 242 | + | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 243 | + | ||
| 244 | + | [[package]] | |
| 245 | + | name = "httpdate" | |
| 246 | + | version = "1.0.3" | |
| 247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 248 | + | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | |
| 249 | + | ||
| 250 | + | [[package]] | |
| 251 | + | name = "hyper" | |
| 252 | + | version = "1.10.1" | |
| 253 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 254 | + | checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" | |
| 255 | + | dependencies = [ | |
| 256 | + | "atomic-waker", | |
| 257 | + | "bytes", | |
| 258 | + | "futures-channel", | |
| 259 | + | "futures-core", | |
| 260 | + | "http", | |
| 261 | + | "http-body", | |
| 262 | + | "httparse", | |
| 263 | + | "httpdate", | |
| 264 | + | "itoa", | |
| 265 | + | "pin-project-lite", | |
| 266 | + | "smallvec", | |
| 267 | + | "tokio", | |
| 268 | + | ] | |
| 269 | + | ||
| 270 | + | [[package]] | |
| 271 | + | name = "hyper-util" | |
| 272 | + | version = "0.1.20" | |
| 273 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 274 | + | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | |
| 275 | + | dependencies = [ | |
| 276 | + | "bytes", | |
| 277 | + | "http", | |
| 278 | + | "http-body", | |
| 279 | + | "hyper", | |
| 280 | + | "pin-project-lite", | |
| 281 | + | "tokio", | |
| 282 | + | "tower-service", | |
| 283 | + | ] | |
| 284 | + | ||
| 285 | + | [[package]] | |
| 96 | 286 | name = "itoa" | |
| 97 | 287 | version = "1.0.18" | |
| 98 | 288 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -119,12 +309,16 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | |||
| 119 | 309 | name = "livechat" | |
| 120 | 310 | version = "0.1.0" | |
| 121 | 311 | dependencies = [ | |
| 312 | + | "async-stream", | |
| 122 | 313 | "async-trait", | |
| 314 | + | "axum", | |
| 123 | 315 | "dashmap", | |
| 124 | 316 | "serde", | |
| 125 | 317 | "serde_json", | |
| 126 | 318 | "thiserror", | |
| 127 | 319 | "tokio", | |
| 320 | + | "tokio-stream", | |
| 321 | + | "tracing", | |
| 128 | 322 | "uuid", | |
| 129 | 323 | ] | |
| 130 | 324 | ||
| @@ -138,12 +332,41 @@ dependencies = [ | |||
| 138 | 332 | ] | |
| 139 | 333 | ||
| 140 | 334 | [[package]] | |
| 335 | + | name = "log" | |
| 336 | + | version = "0.4.33" | |
| 337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 338 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 339 | + | ||
| 340 | + | [[package]] | |
| 341 | + | name = "matchit" | |
| 342 | + | version = "0.8.4" | |
| 343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 344 | + | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" | |
| 345 | + | ||
| 346 | + | [[package]] | |
| 141 | 347 | name = "memchr" | |
| 142 | 348 | version = "2.8.3" | |
| 143 | 349 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | 350 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 145 | 351 | ||
| 146 | 352 | [[package]] | |
| 353 | + | name = "mime" | |
| 354 | + | version = "0.3.17" | |
| 355 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 356 | + | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | |
| 357 | + | ||
| 358 | + | [[package]] | |
| 359 | + | name = "mio" | |
| 360 | + | version = "1.2.2" | |
| 361 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | + | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | |
| 363 | + | dependencies = [ | |
| 364 | + | "libc", | |
| 365 | + | "wasi", | |
| 366 | + | "windows-sys", | |
| 367 | + | ] | |
| 368 | + | ||
| 369 | + | [[package]] | |
| 147 | 370 | name = "once_cell" | |
| 148 | 371 | version = "1.21.4" | |
| 149 | 372 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -163,6 +386,12 @@ dependencies = [ | |||
| 163 | 386 | ] | |
| 164 | 387 | ||
| 165 | 388 | [[package]] | |
| 389 | + | name = "percent-encoding" | |
| 390 | + | version = "2.3.2" | |
| 391 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 392 | + | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 393 | + | ||
| 394 | + | [[package]] | |
| 166 | 395 | name = "pin-project-lite" | |
| 167 | 396 | version = "0.2.17" | |
| 168 | 397 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -208,6 +437,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 208 | 437 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 209 | 438 | ||
| 210 | 439 | [[package]] | |
| 440 | + | name = "ryu" | |
| 441 | + | version = "1.0.23" | |
| 442 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 443 | + | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 444 | + | ||
| 445 | + | [[package]] | |
| 211 | 446 | name = "scopeguard" | |
| 212 | 447 | version = "1.2.0" | |
| 213 | 448 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -257,6 +492,29 @@ dependencies = [ | |||
| 257 | 492 | ] | |
| 258 | 493 | ||
| 259 | 494 | [[package]] | |
| 495 | + | name = "serde_path_to_error" | |
| 496 | + | version = "0.1.20" | |
| 497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 498 | + | checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" | |
| 499 | + | dependencies = [ | |
| 500 | + | "itoa", | |
| 501 | + | "serde", | |
| 502 | + | "serde_core", | |
| 503 | + | ] | |
| 504 | + | ||
| 505 | + | [[package]] | |
| 506 | + | name = "serde_urlencoded" | |
| 507 | + | version = "0.7.1" | |
| 508 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 509 | + | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | |
| 510 | + | dependencies = [ | |
| 511 | + | "form_urlencoded", | |
| 512 | + | "itoa", | |
| 513 | + | "ryu", | |
| 514 | + | "serde", | |
| 515 | + | ] | |
| 516 | + | ||
| 517 | + | [[package]] | |
| 260 | 518 | name = "slab" | |
| 261 | 519 | version = "0.4.12" | |
| 262 | 520 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -269,6 +527,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 269 | 527 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 270 | 528 | ||
| 271 | 529 | [[package]] | |
| 530 | + | name = "socket2" | |
| 531 | + | version = "0.6.5" | |
| 532 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 533 | + | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 534 | + | dependencies = [ | |
| 535 | + | "libc", | |
| 536 | + | "windows-sys", | |
| 537 | + | ] | |
| 538 | + | ||
| 539 | + | [[package]] | |
| 272 | 540 | name = "syn" | |
| 273 | 541 | version = "2.0.119" | |
| 274 | 542 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -291,6 +559,12 @@ dependencies = [ | |||
| 291 | 559 | ] | |
| 292 | 560 | ||
| 293 | 561 | [[package]] | |
| 562 | + | name = "sync_wrapper" | |
| 563 | + | version = "1.0.2" | |
| 564 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 565 | + | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 566 | + | ||
| 567 | + | [[package]] | |
| 294 | 568 | name = "thiserror" | |
| 295 | 569 | version = "2.0.19" | |
| 296 | 570 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -316,8 +590,12 @@ version = "1.53.0" | |||
| 316 | 590 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 317 | 591 | checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" | |
| 318 | 592 | dependencies = [ | |
| 593 | + | "libc", | |
| 594 | + | "mio", | |
| 319 | 595 | "pin-project-lite", | |
| 596 | + | "socket2", | |
| 320 | 597 | "tokio-macros", | |
| 598 | + | "windows-sys", | |
| 321 | 599 | ] | |
| 322 | 600 | ||
| 323 | 601 | [[package]] | |
| @@ -332,6 +610,91 @@ dependencies = [ | |||
| 332 | 610 | ] | |
| 333 | 611 | ||
| 334 | 612 | [[package]] | |
| 613 | + | name = "tokio-stream" | |
| 614 | + | version = "0.1.18" | |
| 615 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 616 | + | checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" | |
| 617 | + | dependencies = [ | |
| 618 | + | "futures-core", | |
| 619 | + | "pin-project-lite", | |
| 620 | + | "tokio", | |
| 621 | + | "tokio-util", | |
| 622 | + | ] | |
| 623 | + | ||
| 624 | + | [[package]] | |
| 625 | + | name = "tokio-util" | |
| 626 | + | version = "0.7.18" | |
| 627 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 628 | + | checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" | |
| 629 | + | dependencies = [ | |
| 630 | + | "bytes", | |
| 631 | + | "futures-core", | |
| 632 | + | "futures-sink", | |
| 633 | + | "pin-project-lite", | |
| 634 | + | "tokio", | |
| 635 | + | ] | |
| 636 | + | ||
| 637 | + | [[package]] | |
| 638 | + | name = "tower" | |
| 639 | + | version = "0.5.3" | |
| 640 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 641 | + | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | |
| 642 | + | dependencies = [ | |
| 643 | + | "futures-core", | |
| 644 | + | "futures-util", | |
| 645 | + | "pin-project-lite", | |
| 646 | + | "sync_wrapper", | |
| 647 | + | "tokio", | |
| 648 | + | "tower-layer", | |
| 649 | + | "tower-service", | |
| 650 | + | "tracing", | |
| 651 | + | ] | |
| 652 | + | ||
| 653 | + | [[package]] | |
| 654 | + | name = "tower-layer" | |
| 655 | + | version = "0.3.3" | |
| 656 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 657 | + | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | |
| 658 | + | ||
| 659 | + | [[package]] | |
| 660 | + | name = "tower-service" | |
| 661 | + | version = "0.3.3" | |
| 662 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 663 | + | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | |
| 664 | + | ||
| 665 | + | [[package]] | |
| 666 | + | name = "tracing" | |
| 667 | + | version = "0.1.44" | |
| 668 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 669 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 670 | + | dependencies = [ | |
| 671 | + | "log", | |
| 672 | + | "pin-project-lite", | |
| 673 | + | "tracing-attributes", | |
| 674 | + | "tracing-core", | |
| 675 | + | ] | |
| 676 | + | ||
| 677 | + | [[package]] | |
| 678 | + | name = "tracing-attributes" | |
| 679 | + | version = "0.1.31" | |
| 680 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 681 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 682 | + | dependencies = [ | |
| 683 | + | "proc-macro2", | |
| 684 | + | "quote", | |
| 685 | + | "syn 2.0.119", | |
| 686 | + | ] | |
| 687 | + | ||
| 688 | + | [[package]] | |
| 689 | + | name = "tracing-core" | |
| 690 | + | version = "0.1.36" | |
| 691 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 692 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 693 | + | dependencies = [ | |
| 694 | + | "once_cell", | |
| 695 | + | ] | |
| 696 | + | ||
| 697 | + | [[package]] | |
| 335 | 698 | name = "unicode-ident" | |
| 336 | 699 | version = "1.0.24" | |
| 337 | 700 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -350,6 +713,12 @@ dependencies = [ | |||
| 350 | 713 | ] | |
| 351 | 714 | ||
| 352 | 715 | [[package]] | |
| 716 | + | name = "wasi" | |
| 717 | + | version = "0.11.1+wasi-snapshot-preview1" | |
| 718 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 719 | + | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 720 | + | ||
| 721 | + | [[package]] | |
| 353 | 722 | name = "wasm-bindgen" | |
| 354 | 723 | version = "0.2.126" | |
| 355 | 724 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -401,6 +770,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 401 | 770 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 402 | 771 | ||
| 403 | 772 | [[package]] | |
| 773 | + | name = "windows-sys" | |
| 774 | + | version = "0.61.2" | |
| 775 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 776 | + | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 777 | + | dependencies = [ | |
| 778 | + | "windows-link", | |
| 779 | + | ] | |
| 780 | + | ||
| 781 | + | [[package]] | |
| 404 | 782 | name = "zmij" | |
| 405 | 783 | version = "1.0.23" | |
| 406 | 784 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3,6 +3,11 @@ name = "livechat" | |||
| 3 | 3 | version = "0.1.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | ||
| 6 | + | [features] | |
| 7 | + | default = [] | |
| 8 | + | # Axum SSE adapter. The rest of the crate has no framework dependency. | |
| 9 | + | axum = ["dep:axum", "dep:async-stream", "dep:tokio-stream", "dep:serde_json", "dep:tracing"] | |
| 10 | + | ||
| 6 | 11 | [dependencies] | |
| 7 | 12 | async-trait = "0.1" | |
| 8 | 13 | dashmap = "6" | |
| @@ -11,6 +16,12 @@ thiserror = "2" | |||
| 11 | 16 | tokio = { version = "1", features = ["sync"] } | |
| 12 | 17 | uuid = { version = "1", features = ["v4", "serde"] } | |
| 13 | 18 | ||
| 19 | + | async-stream = { version = "0.3", optional = true } | |
| 20 | + | axum = { version = "0.8", optional = true } | |
| 21 | + | serde_json = { version = "1", optional = true } | |
| 22 | + | tokio-stream = { version = "0.1", features = ["sync"], optional = true } | |
| 23 | + | tracing = { version = "0.1", optional = true } | |
| 24 | + | ||
| 14 | 25 | [dev-dependencies] | |
| 15 | 26 | serde_json = "1" | |
| 16 | 27 | tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } |
| @@ -44,6 +44,9 @@ mod ids; | |||
| 44 | 44 | mod message; | |
| 45 | 45 | mod retention; | |
| 46 | 46 | mod room; | |
| 47 | + | #[cfg(feature = "axum")] | |
| 48 | + | mod sse; | |
| 49 | + | mod stream; | |
| 47 | 50 | mod traits; | |
| 48 | 51 | ||
| 49 | 52 | pub use error::{ChatError, LimitScope}; | |
| @@ -53,6 +56,9 @@ pub use ids::{MessageId, Nonce, RoomId, UserId}; | |||
| 53 | 56 | pub use message::{MAX_MESSAGE_LEN, Message, MessageBody}; | |
| 54 | 57 | pub use retention::{MAX_AGE_CEILING, MAX_MESSAGES_CEILING, Retention}; | |
| 55 | 58 | pub use room::{Room, RoomState}; | |
| 59 | + | #[cfg(feature = "axum")] | |
| 60 | + | pub use sse::{KEEPALIVE_INTERVAL, sse_response}; | |
| 61 | + | pub use stream::ChatStream; | |
| 56 | 62 | pub use traits::{ | |
| 57 | 63 | ChatAuthz, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, WriteAccess, | |
| 58 | 64 | }; |
| @@ -0,0 +1,147 @@ | |||
| 1 | + | //! Axum SSE adapter. Behind the `axum` feature; the rest of the crate has no | |
| 2 | + | //! framework dependency. | |
| 3 | + | //! | |
| 4 | + | //! SSE rather than a WebSocket, on purpose. Upstream traffic is one `POST` per | |
| 5 | + | //! message typed, so bidirectionality buys nothing, and a `POST` passes through | |
| 6 | + | //! the CSRF, rate-limit, and session middleware both consumers already run. It | |
| 7 | + | //! also avoids adding a `connect-src` CSP directive on the Multithreaded side, | |
| 8 | + | //! which currently has none and falls back to `default-src 'self'`. | |
| 9 | + | ||
| 10 | + | use std::convert::Infallible; | |
| 11 | + | use std::time::Duration; | |
| 12 | + | ||
| 13 | + | use axum::response::sse::{Event, KeepAlive, Sse}; | |
| 14 | + | use tokio_stream::Stream; | |
| 15 | + | ||
| 16 | + | use crate::event::ChatEvent; | |
| 17 | + | use crate::stream::ChatStream; | |
| 18 | + | ||
| 19 | + | /// Keepalive interval. | |
| 20 | + | /// | |
| 21 | + | /// Cloudflare fronts makenot.work and idles a connection out at 100 seconds. A | |
| 22 | + | /// 30 second comment clears that with room for a missed tick, and matches what | |
| 23 | + | /// the SyncKit SSE endpoint already runs in production. | |
| 24 | + | pub const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); | |
| 25 | + | ||
| 26 | + | /// Serialize one event into an SSE frame. | |
| 27 | + | /// | |
| 28 | + | /// The event name is carried in the SSE `event:` field so the client can dispatch | |
| 29 | + | /// without parsing the payload first, and the payload is the same tagged JSON, so | |
| 30 | + | /// a client that prefers to read `type` can. | |
| 31 | + | fn frame(event: &ChatEvent) -> Event { | |
| 32 | + | let data = serde_json::to_string(event).unwrap_or_else(|_| { | |
| 33 | + | // A ChatEvent is plain data and cannot fail to serialize. If that ever | |
| 34 | + | // changes, a dead connection is worse than a frame the client ignores. | |
| 35 | + | tracing::error!("chat event failed to serialize"); | |
| 36 | + | r#"{"type":"gap"}"#.to_owned() | |
| 37 | + | }); | |
| 38 | + | Event::default().event(event.name()).data(data) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Wrap a [`ChatStream`] as an axum SSE response. | |
| 42 | + | /// | |
| 43 | + | /// The stream owns the hub subscription, so dropping the response (which axum | |
| 44 | + | /// does when the client disconnects) releases the connection slot and prunes the | |
| 45 | + | /// room if it was the last listener. There is nothing for a caller to remember to | |
| 46 | + | /// clean up. | |
| 47 | + | pub fn sse_response( | |
| 48 | + | stream: ChatStream, | |
| 49 | + | ) -> Sse<impl Stream<Item = Result<Event, Infallible>> + Send> { | |
| 50 | + | let events = async_stream::stream! { | |
| 51 | + | let mut stream = stream; | |
| 52 | + | while let Some(event) = stream.next().await { | |
| 53 | + | yield Ok(frame(&event)); | |
| 54 | + | } | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | Sse::new(events).keep_alive( | |
| 58 | + | KeepAlive::new() | |
| 59 | + | .interval(KEEPALIVE_INTERVAL) | |
| 60 | + | .text("keepalive"), | |
| 61 | + | ) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | #[cfg(test)] | |
| 65 | + | mod tests { | |
| 66 | + | use super::*; | |
| 67 | + | use crate::ids::{MessageId, RoomId, UserId}; | |
| 68 | + | use crate::message::Message; | |
| 69 | + | use uuid::Uuid; | |
| 70 | + | ||
| 71 | + | fn message(id: i64) -> Message { | |
| 72 | + | Message { | |
| 73 | + | id: MessageId(id), | |
| 74 | + | room_id: RoomId(Uuid::nil()), | |
| 75 | + | author_id: UserId(Uuid::nil()), | |
| 76 | + | body_html: "hi".into(), | |
| 77 | + | created_at: 0, | |
| 78 | + | nonce: None, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The SSE wire format is a contract with the client island, so assert on the | |
| 83 | + | /// bytes rather than on the builder. | |
| 84 | + | /// | |
| 85 | + | /// `Event` exposes no accessor for its encoded form, so this reads the `Debug` | |
| 86 | + | /// output, which embeds the real buffer. If an axum upgrade ever makes that | |
| 87 | + | /// opaque these assertions go quiet rather than failing, so | |
| 88 | + | /// [`debug_output_still_exposes_the_buffer`] guards the technique itself. | |
| 89 | + | fn wire(event: &ChatEvent) -> String { | |
| 90 | + | format!("{:?}", frame(event)) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[test] | |
| 94 | + | fn debug_output_still_exposes_the_buffer() { | |
| 95 | + | assert!( | |
| 96 | + | wire(&ChatEvent::Gap).contains("event: gap\\n"), | |
| 97 | + | "Event's Debug no longer embeds the encoded frame, so the wire-format \ | |
| 98 | + | assertions in this module have stopped checking anything" | |
| 99 | + | ); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[test] | |
| 103 | + | fn every_variant_frames_with_its_own_event_name() { | |
| 104 | + | for (event, name) in [ | |
| 105 | + | (ChatEvent::Message(message(1)), "message"), | |
| 106 | + | (ChatEvent::Delete { id: MessageId(1) }, "delete"), | |
| 107 | + | ( | |
| 108 | + | ChatEvent::Purge { | |
| 109 | + | author_id: UserId(Uuid::nil()), | |
| 110 | + | }, | |
| 111 | + | "purge", | |
| 112 | + | ), | |
| 113 | + | (ChatEvent::Gap, "gap"), | |
| 114 | + | ] { | |
| 115 | + | // Match the SSE `event:` field specifically. A bare `contains(name)` | |
| 116 | + | // would also be satisfied by the tagged JSON in the data field, which | |
| 117 | + | // means it would pass even if the event name were never set. | |
| 118 | + | assert!( | |
| 119 | + | wire(&event).contains(&format!("event: {name}\\n")), | |
| 120 | + | "expected an SSE event field named {name}, got: {}", | |
| 121 | + | wire(&event) | |
| 122 | + | ); | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn body_html_is_carried_verbatim() { | |
| 128 | + | // The host renders through docengine before publishing; the transport | |
| 129 | + | // must not re-encode or re-escape what it was given. | |
| 130 | + | let mut m = message(1); | |
| 131 | + | m.body_html = "<em>hi</em>".into(); | |
| 132 | + | let json = serde_json::to_string(&ChatEvent::Message(m)).unwrap(); | |
| 133 | + | assert!(json.contains(r#"<em>hi</em>"#), "{json}"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn a_frame_never_contains_a_bare_newline() { | |
| 138 | + | // SSE is newline-delimited. A payload with a raw newline would split one | |
| 139 | + | // event into two malformed ones, so the JSON encoding has to be the thing | |
| 140 | + | // that protects the framing. | |
| 141 | + | let mut m = message(1); | |
| 142 | + | m.body_html = "line one\nline two".into(); | |
| 143 | + | let json = serde_json::to_string(&ChatEvent::Message(m)).unwrap(); | |
| 144 | + | assert!(!json.contains('\n'), "raw newline reached the frame: {json}"); | |
| 145 | + | assert!(json.contains(r"\n"), "newline should survive escaped: {json}"); | |
| 146 | + | } | |
| 147 | + | } |
| @@ -0,0 +1,325 @@ | |||
| 1 | + | //! Backlog replay joined to the live feed. | |
| 2 | + | //! | |
| 3 | + | //! # The ordering that matters | |
| 4 | + | //! | |
| 5 | + | //! Every deploy is a symlink swap and a restart with no connection draining, so | |
| 6 | + | //! every client reconnects and asks to resume from a cursor. Serving that means | |
| 7 | + | //! reading stored messages and then following the live feed, and the order those | |
| 8 | + | //! two things happen in is the whole correctness question. | |
| 9 | + | //! | |
| 10 | + | //! Fetch the backlog first and then subscribe, and every message sent between the | |
| 11 | + | //! two is lost: it is too new to be in the query result and too old to be in the | |
| 12 | + | //! channel. Nothing errors, the client just has a hole exactly where it was busy | |
| 13 | + | //! reconnecting, which is the worst possible moment. | |
| 14 | + | //! | |
| 15 | + | //! So [`ChatStream::open`] subscribes first, then fetches. That inverts the | |
| 16 | + | //! problem into an overlap instead of a gap, and an overlap is fixable: anything | |
| 17 | + | //! the live feed replays that the backlog already covered is dropped by id. The | |
| 18 | + | //! host cannot get this wrong from the outside, because it hands over a fetch | |
| 19 | + | //! closure rather than a finished backlog. | |
| 20 | + | ||
| 21 | + | use crate::error::ChatError; | |
| 22 | + | use crate::event::ChatEvent; | |
| 23 | + | use crate::hub::{Hub, Subscription}; | |
| 24 | + | use crate::ids::{MessageId, RoomId, UserId}; | |
| 25 | + | use crate::message::Message; | |
| 26 | + | ||
| 27 | + | /// Backlog replay followed by the live feed, deduplicated across the seam. | |
| 28 | + | pub struct ChatStream { | |
| 29 | + | backlog: std::vec::IntoIter<Message>, | |
| 30 | + | live: Subscription, | |
| 31 | + | /// Highest id served from the backlog. Live messages at or below it are | |
| 32 | + | /// duplicates from the overlap window and are dropped. | |
| 33 | + | high_water: Option<MessageId>, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl ChatStream { | |
| 37 | + | /// Subscribe, then fetch the backlog, then serve one followed by the other. | |
| 38 | + | /// | |
| 39 | + | /// `fetch` receives the client's cursor: `Some(id)` to resume after a known | |
| 40 | + | /// message, `None` for a fresh connection, where the host should return | |
| 41 | + | /// whatever recent window it wants a new arrival to see. It is called after | |
| 42 | + | /// the subscription is live, which is the point. | |
| 43 | + | /// | |
| 44 | + | /// The host is expected to return messages in ascending id order. If it does | |
| 45 | + | /// not, the seam still cannot duplicate (the high-water mark is taken from | |
| 46 | + | /// the maximum, not the last), but the client will see the backlog in | |
| 47 | + | /// whatever order it was given. | |
| 48 | + | /// | |
| 49 | + | /// # Errors | |
| 50 | + | /// | |
| 51 | + | /// [`ChatError::ConnectionLimit`] if the hub is at capacity, or whatever | |
| 52 | + | /// `fetch` fails with. | |
| 53 | + | pub async fn open<F, Fut>( | |
| 54 | + | hub: &Hub, | |
| 55 | + | room: RoomId, | |
| 56 | + | user: UserId, | |
| 57 | + | after: Option<MessageId>, | |
| 58 | + | fetch: F, | |
| 59 | + | ) -> Result<Self, ChatError> | |
| 60 | + | where | |
| 61 | + | F: FnOnce(Option<MessageId>) -> Fut, | |
| 62 | + | Fut: Future<Output = Result<Vec<Message>, ChatError>>, | |
| 63 | + | { | |
| 64 | + | // Subscribe first. Everything published from here on is buffered for us | |
| 65 | + | // while the fetch runs, so the two sources overlap rather than gap. | |
| 66 | + | let live = hub.subscribe(room, user)?; | |
| 67 | + | ||
| 68 | + | let backlog = fetch(after).await?; | |
| 69 | + | let high_water = backlog.iter().map(|m| m.id).max(); | |
| 70 | + | ||
| 71 | + | Ok(Self { | |
| 72 | + | backlog: backlog.into_iter(), | |
| 73 | + | live, | |
| 74 | + | high_water, | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Next event, or `None` when the room is gone. | |
| 79 | + | /// | |
| 80 | + | /// Serves the backlog to exhaustion first, then the live feed. | |
| 81 | + | pub async fn next(&mut self) -> Option<ChatEvent> { | |
| 82 | + | if let Some(message) = self.backlog.next() { | |
| 83 | + | return Some(ChatEvent::Message(message)); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | loop { | |
| 87 | + | let event = self.live.recv().await?; | |
| 88 | + | ||
| 89 | + | // Drop only what the backlog already served. Deletes and purges pass | |
| 90 | + | // through regardless: a delete that lands during the overlap refers | |
| 91 | + | // to a message the client has, so suppressing it would leave that | |
| 92 | + | // message on screen forever. | |
| 93 | + | if let ChatEvent::Message(m) = &event | |
| 94 | + | && let Some(high) = self.high_water | |
| 95 | + | && m.id <= high | |
| 96 | + | { | |
| 97 | + | continue; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return Some(event); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// The room being served. | |
| 105 | + | pub fn room(&self) -> RoomId { | |
| 106 | + | self.live.room() | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[cfg(test)] | |
| 111 | + | mod tests { | |
| 112 | + | use super::*; | |
| 113 | + | use crate::hub::HubLimits; | |
| 114 | + | use crate::ids::Nonce; | |
| 115 | + | use std::sync::Arc; | |
| 116 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 117 | + | use uuid::Uuid; | |
| 118 | + | ||
| 119 | + | fn message(room: RoomId, id: i64) -> Message { | |
| 120 | + | Message { | |
| 121 | + | id: MessageId(id), | |
| 122 | + | room_id: room, | |
| 123 | + | author_id: UserId(Uuid::nil()), | |
| 124 | + | body_html: format!("m{id}"), | |
| 125 | + | created_at: 0, | |
| 126 | + | nonce: None, | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn ids(events: &[ChatEvent]) -> Vec<i64> { | |
| 131 | + | events | |
| 132 | + | .iter() | |
| 133 | + | .filter_map(|e| match e { | |
| 134 | + | ChatEvent::Message(m) => Some(m.id.0), | |
| 135 | + | _ => None, | |
| 136 | + | }) | |
| 137 | + | .collect() | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | async fn drain(stream: &mut ChatStream, n: usize) -> Vec<ChatEvent> { | |
| 141 | + | let mut out = Vec::new(); | |
| 142 | + | for _ in 0..n { | |
| 143 | + | match tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()).await { | |
| 144 | + | Ok(Some(e)) => out.push(e), | |
| 145 | + | _ => break, | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | out | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[tokio::test] | |
| 152 | + | async fn backlog_is_served_before_live() { | |
| 153 | + | let hub = Hub::new(HubLimits::default()); | |
| 154 | + | let room = RoomId(Uuid::new_v4()); | |
| 155 | + | ||
| 156 | + | let mut stream = ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async { | |
| 157 | + | Ok(vec![message(room, 1), message(room, 2)]) | |
| 158 | + | }) | |
| 159 | + | .await | |
| 160 | + | .unwrap(); | |
| 161 | + | ||
| 162 | + | hub.publish(room, ChatEvent::Message(message(room, 3))); | |
| 163 | + | ||
| 164 | + | assert_eq!(ids(&drain(&mut stream, 3).await), vec![1, 2, 3]); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[tokio::test] | |
| 168 | + | async fn a_message_published_during_the_fetch_is_not_lost() { | |
| 169 | + | // The failure this exists for: fetch-then-subscribe drops anything sent | |
| 170 | + | // in the window between the two. | |
| 171 | + | let hub = Hub::new(HubLimits::default()); | |
| 172 | + | let room = RoomId(Uuid::new_v4()); | |
| 173 | + | let hub_for_fetch = hub.clone(); | |
| 174 | + | ||
| 175 | + | let mut stream = ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async move { | |
| 176 | + | // Published while the fetch is in flight, and deliberately absent | |
| 177 | + | // from what the fetch returns, exactly as a real query would miss a | |
| 178 | + | // row committed after it ran. | |
| 179 | + | hub_for_fetch.publish(room, ChatEvent::Message(message(room, 7))); | |
| 180 | + | Ok(vec![message(room, 5), message(room, 6)]) | |
| 181 | + | }) | |
| 182 | + | .await | |
| 183 | + | .unwrap(); | |
| 184 | + | ||
| 185 | + | assert_eq!( | |
| 186 | + | ids(&drain(&mut stream, 3).await), | |
| 187 | + | vec![5, 6, 7], | |
| 188 | + | "the message sent during the fetch must still arrive" | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[tokio::test] | |
| 193 | + | async fn the_overlap_does_not_duplicate() { | |
| 194 | + | // The other side of subscribe-first: a message can appear in both the | |
| 195 | + | // backlog and the live feed. It must be served once. | |
| 196 | + | let hub = Hub::new(HubLimits::default()); | |
| 197 | + | let room = RoomId(Uuid::new_v4()); | |
| 198 | + | let hub_for_fetch = hub.clone(); | |
| 199 | + | ||
| 200 | + | let mut stream = ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async move { | |
| 201 | + | hub_for_fetch.publish(room, ChatEvent::Message(message(room, 2))); | |
| 202 | + | hub_for_fetch.publish(room, ChatEvent::Message(message(room, 3))); | |
| 203 | + | // The fetch sees both, because they committed before it ran. | |
| 204 | + | Ok(vec![message(room, 1), message(room, 2), message(room, 3)]) | |
| 205 | + | }) | |
| 206 | + | .await | |
| 207 | + | .unwrap(); | |
| 208 | + | ||
| 209 | + | hub.publish(room, ChatEvent::Message(message(room, 4))); | |
| 210 | + | ||
| 211 | + | assert_eq!(ids(&drain(&mut stream, 4).await), vec![1, 2, 3, 4]); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[tokio::test] | |
| 215 | + | async fn deletes_in_the_overlap_are_not_suppressed() { | |
| 216 | + | // A delete carries no id that can be compared against the high-water | |
| 217 | + | // mark, and it refers to a message the client already has. Dropping it | |
| 218 | + | // would leave a deleted message on screen until reload. | |
| 219 | + | let hub = Hub::new(HubLimits::default()); | |
| 220 | + | let room = RoomId(Uuid::new_v4()); | |
| 221 | + | let hub_for_fetch = hub.clone(); | |
| 222 | + | ||
| 223 | + | let mut stream = ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async move { | |
| 224 | + | hub_for_fetch.publish( | |
| 225 | + | room, | |
| 226 | + | ChatEvent::Delete { | |
| 227 | + | id: MessageId(1), | |
| 228 | + | }, | |
| 229 | + | ); | |
| 230 | + | Ok(vec![message(room, 1), message(room, 2)]) | |
| 231 | + | }) | |
| 232 | + | .await | |
| 233 | + | .unwrap(); | |
| 234 | + | ||
| 235 | + | let events = drain(&mut stream, 3).await; | |
| 236 | + | assert!( | |
| 237 | + | events.iter().any(|e| matches!( | |
| 238 | + | e, | |
| 239 | + | ChatEvent::Delete { | |
| 240 | + | id: MessageId(1) | |
| 241 | + | } | |
| 242 | + | )), | |
| 243 | + | "delete during the overlap must pass through: {events:?}" | |
| 244 | + | ); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[tokio::test] | |
| 248 | + | async fn the_cursor_reaches_the_fetch() { | |
| 249 | + | let hub = Hub::new(HubLimits::default()); | |
| 250 | + | let room = RoomId(Uuid::new_v4()); | |
| 251 | + | let seen = Arc::new(AtomicUsize::new(0)); | |
| 252 | + | let seen_clone = seen.clone(); | |
| 253 | + | ||
| 254 | + | let _stream = ChatStream::open( | |
| 255 | + | &hub, | |
| 256 | + | room, | |
| 257 | + | UserId(Uuid::nil()), | |
| 258 | + | Some(MessageId(42)), | |
| 259 | + | move |after| async move { | |
| 260 | + | assert_eq!(after, Some(MessageId(42))); | |
| 261 | + | seen_clone.fetch_add(1, Ordering::SeqCst); | |
| 262 | + | Ok(vec![]) | |
| 263 | + | }, | |
| 264 | + | ) | |
| 265 | + | .await | |
| 266 | + | .unwrap(); | |
| 267 | + | ||
| 268 | + | assert_eq!(seen.load(Ordering::SeqCst), 1); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[tokio::test] | |
| 272 | + | async fn an_empty_backlog_goes_straight_to_live() { | |
| 273 | + | let hub = Hub::new(HubLimits::default()); | |
| 274 | + | let room = RoomId(Uuid::new_v4()); | |
| 275 | + | ||
| 276 | + | let mut stream = | |
| 277 | + | ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async { Ok(vec![]) }) | |
| 278 | + | .await | |
| 279 | + | .unwrap(); | |
| 280 | + | ||
| 281 | + | hub.publish(room, ChatEvent::Message(message(room, 1))); | |
| 282 | + | assert_eq!(ids(&drain(&mut stream, 1).await), vec![1]); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[tokio::test] | |
| 286 | + | async fn a_failing_fetch_releases_the_connection_slot() { | |
| 287 | + | let hub = Hub::new(HubLimits::default()); | |
| 288 | + | let room = RoomId(Uuid::new_v4()); | |
| 289 | + | ||
| 290 | + | let result = ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async { | |
| 291 | + | Err(ChatError::MessageEmpty) | |
| 292 | + | }) | |
| 293 | + | .await; | |
| 294 | + | ||
| 295 | + | assert!(result.is_err()); | |
| 296 | + | assert_eq!( | |
| 297 | + | hub.connection_count(), | |
| 298 | + | 0, | |
| 299 | + | "the subscription taken before the fetch must be released when it fails" | |
| 300 | + | ); | |
| 301 | + | assert_eq!(hub.room_count(), 0); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[tokio::test] | |
| 305 | + | async fn the_nonce_survives_the_seam() { | |
| 306 | + | // The sender's own optimistic message must arrive carrying its nonce, or | |
| 307 | + | // the client cannot reconcile and shows it twice. | |
| 308 | + | let hub = Hub::new(HubLimits::default()); | |
| 309 | + | let room = RoomId(Uuid::new_v4()); | |
| 310 | + | ||
| 311 | + | let mut stream = | |
| 312 | + | ChatStream::open(&hub, room, UserId(Uuid::nil()), None, |_| async { Ok(vec![]) }) | |
| 313 | + | .await | |
| 314 | + | .unwrap(); | |
| 315 | + | ||
| 316 | + | let mut m = message(room, 1); | |
| 317 | + | m.nonce = Nonce::parse("abc"); | |
| 318 | + | hub.publish(room, ChatEvent::Message(m)); | |
| 319 | + | ||
| 320 | + | match drain(&mut stream, 1).await.first() { | |
| 321 | + | Some(ChatEvent::Message(m)) => assert_eq!(m.nonce, Nonce::parse("abc")), | |
| 322 | + | other => panic!("expected a message carrying its nonce, got {other:?}"), | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | } |