interactive intro to open social at-me.zzstoatzz.io

feat: add jetstream firehose backend with SSE streaming #10

closed opened by zzstoatzz.io targeting main from feat/firehose-visualization

adds rust backend for real-time firehose visualization:

  • firehose module connects to jetstream and broadcasts all atproto events
  • uses rocketman crate with lexicon ingester pattern
  • SSE endpoint at /api/firehose/watch streams filtered events by DID
  • auto-reconnects on connection drop

remaining work:

  • add UI toggle button and toast notifications in templates
  • implement particle animation system in static/app.js
  • test with live events

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xbtmt2zjwlrfegqvch7fboei/sh.tangled.repo.pull/3m33hhu6p7c22
+1274 -7
Diff #0
+446 -4
Cargo.lock
··· 275 "subtle", 276 ] 277 278 [[package]] 279 name = "aho-corasick" 280 version = "1.1.3" ··· 394 "pin-project-lite", 395 ] 396 397 [[package]] 398 name = "async-trait" 399 version = "0.1.89" ··· 412 "actix-files", 413 "actix-session", 414 "actix-web", 415 "atrium-api", 416 "atrium-common", 417 "atrium-identity", 418 "atrium-oauth", 419 "env_logger", 420 "hickory-resolver", 421 "log", 422 "reqwest", 423 "serde", 424 "serde_json", 425 "tokio", ··· 575 source = "registry+https://github.com/rust-lang/crates.io-index" 576 checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" 577 578 [[package]] 579 name = "base64" 580 version = "0.22.1" ··· 602 "generic-array", 603 ] 604 605 [[package]] 606 name = "brotli" 607 version = "8.0.2" ··· 629 source = "registry+https://github.com/rust-lang/crates.io-index" 630 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 631 632 [[package]] 633 name = "bytes" 634 version = "1.10.1" ··· 861 "cipher", 862 ] 863 864 [[package]] 865 name = "dashmap" 866 version = "6.1.0" ··· 920 "powerfmt", 921 ] 922 923 [[package]] 924 name = "derive_more" 925 version = "1.0.0" ··· 1129 "miniz_oxide", 1130 ] 1131 1132 [[package]] 1133 name = "fnv" 1134 version = "1.0.7" ··· 1217 dependencies = [ 1218 "futures-core", 1219 "futures-macro", 1220 "futures-task", 1221 "pin-project-lite", 1222 "pin-utils", ··· 1241 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1242 dependencies = [ 1243 "cfg-if", 1244 "libc", 1245 "wasi 0.11.1+wasi-snapshot-preview1", 1246 ] 1247 1248 [[package]] ··· 1508 "http 1.3.1", 1509 "hyper", 1510 "hyper-util", 1511 - "rustls", 1512 "rustls-pki-types", 1513 "tokio", 1514 - "tokio-rustls", 1515 "tower-service", 1516 ] 1517 ··· 1667 "zerovec", 1668 ] 1669 1670 [[package]] 1671 name = "idna" 1672 version = "1.1.0" ··· 1868 source = "registry+https://github.com/rust-lang/crates.io-index" 1869 checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 1870 1871 [[package]] 1872 name = "libc" 1873 version = "0.2.176" ··· 1959 source = "registry+https://github.com/rust-lang/crates.io-index" 1960 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1961 1962 [[package]] 1963 name = "mime" 1964 version = "0.3.17" ··· 2041 "unsigned-varint", 2042 ] 2043 2044 [[package]] 2045 name = "native-tls" 2046 version = "0.2.14" ··· 2058 "tempfile", 2059 ] 2060 2061 [[package]] 2062 name = "num-conv" 2063 version = "0.1.0" ··· 2260 "zerocopy", 2261 ] 2262 2263 [[package]] 2264 name = "primeorder" 2265 version = "0.13.6" ··· 2469 "windows-sys 0.52.0", 2470 ] 2471 2472 [[package]] 2473 name = "rustc-demangle" 2474 version = "0.1.26" ··· 2497 "windows-sys 0.61.1", 2498 ] 2499 2500 [[package]] 2501 name = "rustls" 2502 version = "0.23.31" ··· 2505 dependencies = [ 2506 "once_cell", 2507 "rustls-pki-types", 2508 - "rustls-webpki", 2509 "subtle", 2510 "zeroize", 2511 ] 2512 2513 [[package]] 2514 name = "rustls-pki-types" 2515 version = "1.12.0" ··· 2519 "zeroize", 2520 ] 2521 2522 [[package]] 2523 name = "rustls-webpki" 2524 version = "0.103.4" ··· 2557 source = "registry+https://github.com/rust-lang/crates.io-index" 2558 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2559 2560 [[package]] 2561 name = "sec1" 2562 version = "0.7.3" ··· 2699 "digest", 2700 ] 2701 2702 [[package]] 2703 name = "shlex" 2704 version = "1.3.0" ··· 2762 "windows-sys 0.59.0", 2763 ] 2764 2765 [[package]] 2766 name = "stable_deref_trait" 2767 version = "1.2.0" 2768 source = "registry+https://github.com/rust-lang/crates.io-index" 2769 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 2770 2771 [[package]] 2772 name = "subtle" 2773 version = "2.6.1" ··· 2876 "syn 2.0.106", 2877 ] 2878 2879 [[package]] 2880 name = "time" 2881 version = "0.3.44" ··· 2973 "tokio", 2974 ] 2975 2976 [[package]] 2977 name = "tokio-rustls" 2978 version = "0.26.2" 2979 source = "registry+https://github.com/rust-lang/crates.io-index" 2980 checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 2981 dependencies = [ 2982 - "rustls", 2983 "tokio", 2984 ] 2985 2986 [[package]] ··· 3071 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3072 dependencies = [ 3073 "once_cell", 3074 ] 3075 3076 [[package]] ··· 3090 source = "registry+https://github.com/rust-lang/crates.io-index" 3091 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3092 3093 [[package]] 3094 name = "typenum" 3095 version = "1.19.0" ··· 3148 "serde", 3149 ] 3150 3151 [[package]] 3152 name = "utf8_iter" 3153 version = "1.0.4" ··· 3177 source = "registry+https://github.com/rust-lang/crates.io-index" 3178 checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3179 3180 [[package]] 3181 name = "vcpkg" 3182 version = "0.2.15" ··· 3314 "wasm-bindgen", 3315 ] 3316 3317 [[package]] 3318 name = "widestring" 3319 version = "1.2.0"
··· 275 "subtle", 276 ] 277 278 + [[package]] 279 + name = "ahash" 280 + version = "0.8.12" 281 + source = "registry+https://github.com/rust-lang/crates.io-index" 282 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 283 + dependencies = [ 284 + "cfg-if", 285 + "once_cell", 286 + "version_check", 287 + "zerocopy", 288 + ] 289 + 290 [[package]] 291 name = "aho-corasick" 292 version = "1.1.3" ··· 406 "pin-project-lite", 407 ] 408 409 + [[package]] 410 + name = "async-stream" 411 + version = "0.3.6" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 414 + dependencies = [ 415 + "async-stream-impl", 416 + "futures-core", 417 + "pin-project-lite", 418 + ] 419 + 420 + [[package]] 421 + name = "async-stream-impl" 422 + version = "0.3.6" 423 + source = "registry+https://github.com/rust-lang/crates.io-index" 424 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 425 + dependencies = [ 426 + "proc-macro2", 427 + "quote", 428 + "syn 2.0.106", 429 + ] 430 + 431 [[package]] 432 name = "async-trait" 433 version = "0.1.89" ··· 446 "actix-files", 447 "actix-session", 448 "actix-web", 449 + "anyhow", 450 + "async-stream", 451 + "async-trait", 452 "atrium-api", 453 "atrium-common", 454 "atrium-identity", 455 "atrium-oauth", 456 "env_logger", 457 + "futures-util", 458 "hickory-resolver", 459 "log", 460 "reqwest", 461 + "rocketman", 462 "serde", 463 "serde_json", 464 "tokio", ··· 614 source = "registry+https://github.com/rust-lang/crates.io-index" 615 checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" 616 617 + [[package]] 618 + name = "base64" 619 + version = "0.21.7" 620 + source = "registry+https://github.com/rust-lang/crates.io-index" 621 + checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 622 + 623 [[package]] 624 name = "base64" 625 version = "0.22.1" ··· 647 "generic-array", 648 ] 649 650 + [[package]] 651 + name = "bon" 652 + version = "3.8.1" 653 + source = "registry+https://github.com/rust-lang/crates.io-index" 654 + checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" 655 + dependencies = [ 656 + "bon-macros", 657 + "rustversion", 658 + ] 659 + 660 + [[package]] 661 + name = "bon-macros" 662 + version = "3.8.1" 663 + source = "registry+https://github.com/rust-lang/crates.io-index" 664 + checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 665 + dependencies = [ 666 + "darling 0.21.3", 667 + "ident_case", 668 + "prettyplease", 669 + "proc-macro2", 670 + "quote", 671 + "rustversion", 672 + "syn 2.0.106", 673 + ] 674 + 675 [[package]] 676 name = "brotli" 677 version = "8.0.2" ··· 699 source = "registry+https://github.com/rust-lang/crates.io-index" 700 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 701 702 + [[package]] 703 + name = "byteorder" 704 + version = "1.5.0" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 707 + 708 [[package]] 709 name = "bytes" 710 version = "1.10.1" ··· 937 "cipher", 938 ] 939 940 + [[package]] 941 + name = "darling" 942 + version = "0.20.11" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 945 + dependencies = [ 946 + "darling_core 0.20.11", 947 + "darling_macro 0.20.11", 948 + ] 949 + 950 + [[package]] 951 + name = "darling" 952 + version = "0.21.3" 953 + source = "registry+https://github.com/rust-lang/crates.io-index" 954 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 955 + dependencies = [ 956 + "darling_core 0.21.3", 957 + "darling_macro 0.21.3", 958 + ] 959 + 960 + [[package]] 961 + name = "darling_core" 962 + version = "0.20.11" 963 + source = "registry+https://github.com/rust-lang/crates.io-index" 964 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 965 + dependencies = [ 966 + "fnv", 967 + "ident_case", 968 + "proc-macro2", 969 + "quote", 970 + "strsim", 971 + "syn 2.0.106", 972 + ] 973 + 974 + [[package]] 975 + name = "darling_core" 976 + version = "0.21.3" 977 + source = "registry+https://github.com/rust-lang/crates.io-index" 978 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 979 + dependencies = [ 980 + "fnv", 981 + "ident_case", 982 + "proc-macro2", 983 + "quote", 984 + "strsim", 985 + "syn 2.0.106", 986 + ] 987 + 988 + [[package]] 989 + name = "darling_macro" 990 + version = "0.20.11" 991 + source = "registry+https://github.com/rust-lang/crates.io-index" 992 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 993 + dependencies = [ 994 + "darling_core 0.20.11", 995 + "quote", 996 + "syn 2.0.106", 997 + ] 998 + 999 + [[package]] 1000 + name = "darling_macro" 1001 + version = "0.21.3" 1002 + source = "registry+https://github.com/rust-lang/crates.io-index" 1003 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 1004 + dependencies = [ 1005 + "darling_core 0.21.3", 1006 + "quote", 1007 + "syn 2.0.106", 1008 + ] 1009 + 1010 [[package]] 1011 name = "dashmap" 1012 version = "6.1.0" ··· 1066 "powerfmt", 1067 ] 1068 1069 + [[package]] 1070 + name = "derive_builder" 1071 + version = "0.20.2" 1072 + source = "registry+https://github.com/rust-lang/crates.io-index" 1073 + checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 1074 + dependencies = [ 1075 + "derive_builder_macro", 1076 + ] 1077 + 1078 + [[package]] 1079 + name = "derive_builder_core" 1080 + version = "0.20.2" 1081 + source = "registry+https://github.com/rust-lang/crates.io-index" 1082 + checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 1083 + dependencies = [ 1084 + "darling 0.20.11", 1085 + "proc-macro2", 1086 + "quote", 1087 + "syn 2.0.106", 1088 + ] 1089 + 1090 + [[package]] 1091 + name = "derive_builder_macro" 1092 + version = "0.20.2" 1093 + source = "registry+https://github.com/rust-lang/crates.io-index" 1094 + checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 1095 + dependencies = [ 1096 + "derive_builder_core", 1097 + "syn 2.0.106", 1098 + ] 1099 + 1100 [[package]] 1101 name = "derive_more" 1102 version = "1.0.0" ··· 1306 "miniz_oxide", 1307 ] 1308 1309 + [[package]] 1310 + name = "flume" 1311 + version = "0.11.1" 1312 + source = "registry+https://github.com/rust-lang/crates.io-index" 1313 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 1314 + dependencies = [ 1315 + "futures-core", 1316 + "futures-sink", 1317 + "nanorand", 1318 + "spin", 1319 + ] 1320 + 1321 [[package]] 1322 name = "fnv" 1323 version = "1.0.7" ··· 1406 dependencies = [ 1407 "futures-core", 1408 "futures-macro", 1409 + "futures-sink", 1410 "futures-task", 1411 "pin-project-lite", 1412 "pin-utils", ··· 1431 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1432 dependencies = [ 1433 "cfg-if", 1434 + "js-sys", 1435 "libc", 1436 "wasi 0.11.1+wasi-snapshot-preview1", 1437 + "wasm-bindgen", 1438 ] 1439 1440 [[package]] ··· 1700 "http 1.3.1", 1701 "hyper", 1702 "hyper-util", 1703 + "rustls 0.23.31", 1704 "rustls-pki-types", 1705 "tokio", 1706 + "tokio-rustls 0.26.2", 1707 "tower-service", 1708 ] 1709 ··· 1859 "zerovec", 1860 ] 1861 1862 + [[package]] 1863 + name = "ident_case" 1864 + version = "1.0.1" 1865 + source = "registry+https://github.com/rust-lang/crates.io-index" 1866 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1867 + 1868 [[package]] 1869 name = "idna" 1870 version = "1.1.0" ··· 2066 source = "registry+https://github.com/rust-lang/crates.io-index" 2067 checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 2068 2069 + [[package]] 2070 + name = "lazy_static" 2071 + version = "1.5.0" 2072 + source = "registry+https://github.com/rust-lang/crates.io-index" 2073 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2074 + 2075 [[package]] 2076 name = "libc" 2077 version = "0.2.176" ··· 2163 source = "registry+https://github.com/rust-lang/crates.io-index" 2164 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 2165 2166 + [[package]] 2167 + name = "metrics" 2168 + version = "0.24.2" 2169 + source = "registry+https://github.com/rust-lang/crates.io-index" 2170 + checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5" 2171 + dependencies = [ 2172 + "ahash", 2173 + "portable-atomic", 2174 + ] 2175 + 2176 [[package]] 2177 name = "mime" 2178 version = "0.3.17" ··· 2255 "unsigned-varint", 2256 ] 2257 2258 + [[package]] 2259 + name = "nanorand" 2260 + version = "0.7.0" 2261 + source = "registry+https://github.com/rust-lang/crates.io-index" 2262 + checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 2263 + dependencies = [ 2264 + "getrandom 0.2.16", 2265 + ] 2266 + 2267 [[package]] 2268 name = "native-tls" 2269 version = "0.2.14" ··· 2281 "tempfile", 2282 ] 2283 2284 + [[package]] 2285 + name = "nu-ansi-term" 2286 + version = "0.50.3" 2287 + source = "registry+https://github.com/rust-lang/crates.io-index" 2288 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2289 + dependencies = [ 2290 + "windows-sys 0.61.1", 2291 + ] 2292 + 2293 [[package]] 2294 name = "num-conv" 2295 version = "0.1.0" ··· 2492 "zerocopy", 2493 ] 2494 2495 + [[package]] 2496 + name = "prettyplease" 2497 + version = "0.2.37" 2498 + source = "registry+https://github.com/rust-lang/crates.io-index" 2499 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2500 + dependencies = [ 2501 + "proc-macro2", 2502 + "syn 2.0.106", 2503 + ] 2504 + 2505 [[package]] 2506 name = "primeorder" 2507 version = "0.13.6" ··· 2711 "windows-sys 0.52.0", 2712 ] 2713 2714 + [[package]] 2715 + name = "rocketman" 2716 + version = "0.2.5" 2717 + source = "registry+https://github.com/rust-lang/crates.io-index" 2718 + checksum = "90cfc4ee9daf6e9d0ee217b9709aa3bd6c921e6926aa15c6ff5ba9162c2c649a" 2719 + dependencies = [ 2720 + "anyhow", 2721 + "async-trait", 2722 + "bon", 2723 + "derive_builder", 2724 + "flume", 2725 + "futures-util", 2726 + "metrics", 2727 + "rand 0.8.5", 2728 + "serde", 2729 + "serde_json", 2730 + "tokio", 2731 + "tokio-tungstenite", 2732 + "tracing", 2733 + "tracing-subscriber", 2734 + "url", 2735 + "zstd", 2736 + ] 2737 + 2738 [[package]] 2739 name = "rustc-demangle" 2740 version = "0.1.26" ··· 2763 "windows-sys 0.61.1", 2764 ] 2765 2766 + [[package]] 2767 + name = "rustls" 2768 + version = "0.21.12" 2769 + source = "registry+https://github.com/rust-lang/crates.io-index" 2770 + checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" 2771 + dependencies = [ 2772 + "log", 2773 + "ring", 2774 + "rustls-webpki 0.101.7", 2775 + "sct", 2776 + ] 2777 + 2778 [[package]] 2779 name = "rustls" 2780 version = "0.23.31" ··· 2783 dependencies = [ 2784 "once_cell", 2785 "rustls-pki-types", 2786 + "rustls-webpki 0.103.4", 2787 "subtle", 2788 "zeroize", 2789 ] 2790 2791 + [[package]] 2792 + name = "rustls-native-certs" 2793 + version = "0.6.3" 2794 + source = "registry+https://github.com/rust-lang/crates.io-index" 2795 + checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" 2796 + dependencies = [ 2797 + "openssl-probe", 2798 + "rustls-pemfile", 2799 + "schannel", 2800 + "security-framework", 2801 + ] 2802 + 2803 + [[package]] 2804 + name = "rustls-pemfile" 2805 + version = "1.0.4" 2806 + source = "registry+https://github.com/rust-lang/crates.io-index" 2807 + checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" 2808 + dependencies = [ 2809 + "base64 0.21.7", 2810 + ] 2811 + 2812 [[package]] 2813 name = "rustls-pki-types" 2814 version = "1.12.0" ··· 2818 "zeroize", 2819 ] 2820 2821 + [[package]] 2822 + name = "rustls-webpki" 2823 + version = "0.101.7" 2824 + source = "registry+https://github.com/rust-lang/crates.io-index" 2825 + checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" 2826 + dependencies = [ 2827 + "ring", 2828 + "untrusted", 2829 + ] 2830 + 2831 [[package]] 2832 name = "rustls-webpki" 2833 version = "0.103.4" ··· 2866 source = "registry+https://github.com/rust-lang/crates.io-index" 2867 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2868 2869 + [[package]] 2870 + name = "sct" 2871 + version = "0.7.1" 2872 + source = "registry+https://github.com/rust-lang/crates.io-index" 2873 + checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" 2874 + dependencies = [ 2875 + "ring", 2876 + "untrusted", 2877 + ] 2878 + 2879 [[package]] 2880 name = "sec1" 2881 version = "0.7.3" ··· 3018 "digest", 3019 ] 3020 3021 + [[package]] 3022 + name = "sharded-slab" 3023 + version = "0.1.7" 3024 + source = "registry+https://github.com/rust-lang/crates.io-index" 3025 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3026 + dependencies = [ 3027 + "lazy_static", 3028 + ] 3029 + 3030 [[package]] 3031 name = "shlex" 3032 version = "1.3.0" ··· 3090 "windows-sys 0.59.0", 3091 ] 3092 3093 + [[package]] 3094 + name = "spin" 3095 + version = "0.9.8" 3096 + source = "registry+https://github.com/rust-lang/crates.io-index" 3097 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3098 + dependencies = [ 3099 + "lock_api", 3100 + ] 3101 + 3102 [[package]] 3103 name = "stable_deref_trait" 3104 version = "1.2.0" 3105 source = "registry+https://github.com/rust-lang/crates.io-index" 3106 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 3107 3108 + [[package]] 3109 + name = "strsim" 3110 + version = "0.11.1" 3111 + source = "registry+https://github.com/rust-lang/crates.io-index" 3112 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3113 + 3114 [[package]] 3115 name = "subtle" 3116 version = "2.6.1" ··· 3219 "syn 2.0.106", 3220 ] 3221 3222 + [[package]] 3223 + name = "thread_local" 3224 + version = "1.1.9" 3225 + source = "registry+https://github.com/rust-lang/crates.io-index" 3226 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3227 + dependencies = [ 3228 + "cfg-if", 3229 + ] 3230 + 3231 [[package]] 3232 name = "time" 3233 version = "0.3.44" ··· 3325 "tokio", 3326 ] 3327 3328 + [[package]] 3329 + name = "tokio-rustls" 3330 + version = "0.24.1" 3331 + source = "registry+https://github.com/rust-lang/crates.io-index" 3332 + checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 3333 + dependencies = [ 3334 + "rustls 0.21.12", 3335 + "tokio", 3336 + ] 3337 + 3338 [[package]] 3339 name = "tokio-rustls" 3340 version = "0.26.2" 3341 source = "registry+https://github.com/rust-lang/crates.io-index" 3342 checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3343 dependencies = [ 3344 + "rustls 0.23.31", 3345 + "tokio", 3346 + ] 3347 + 3348 + [[package]] 3349 + name = "tokio-tungstenite" 3350 + version = "0.20.1" 3351 + source = "registry+https://github.com/rust-lang/crates.io-index" 3352 + checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" 3353 + dependencies = [ 3354 + "futures-util", 3355 + "log", 3356 + "rustls 0.21.12", 3357 + "rustls-native-certs", 3358 "tokio", 3359 + "tokio-rustls 0.24.1", 3360 + "tungstenite", 3361 + "webpki-roots", 3362 ] 3363 3364 [[package]] ··· 3449 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3450 dependencies = [ 3451 "once_cell", 3452 + "valuable", 3453 + ] 3454 + 3455 + [[package]] 3456 + name = "tracing-log" 3457 + version = "0.2.0" 3458 + source = "registry+https://github.com/rust-lang/crates.io-index" 3459 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3460 + dependencies = [ 3461 + "log", 3462 + "once_cell", 3463 + "tracing-core", 3464 + ] 3465 + 3466 + [[package]] 3467 + name = "tracing-subscriber" 3468 + version = "0.3.20" 3469 + source = "registry+https://github.com/rust-lang/crates.io-index" 3470 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 3471 + dependencies = [ 3472 + "nu-ansi-term", 3473 + "sharded-slab", 3474 + "smallvec", 3475 + "thread_local", 3476 + "tracing-core", 3477 + "tracing-log", 3478 ] 3479 3480 [[package]] ··· 3494 source = "registry+https://github.com/rust-lang/crates.io-index" 3495 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3496 3497 + [[package]] 3498 + name = "tungstenite" 3499 + version = "0.20.1" 3500 + source = "registry+https://github.com/rust-lang/crates.io-index" 3501 + checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" 3502 + dependencies = [ 3503 + "byteorder", 3504 + "bytes", 3505 + "data-encoding", 3506 + "http 0.2.12", 3507 + "httparse", 3508 + "log", 3509 + "rand 0.8.5", 3510 + "rustls 0.21.12", 3511 + "sha1", 3512 + "thiserror", 3513 + "url", 3514 + "utf-8", 3515 + ] 3516 + 3517 [[package]] 3518 name = "typenum" 3519 version = "1.19.0" ··· 3572 "serde", 3573 ] 3574 3575 + [[package]] 3576 + name = "utf-8" 3577 + version = "0.7.6" 3578 + source = "registry+https://github.com/rust-lang/crates.io-index" 3579 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3580 + 3581 [[package]] 3582 name = "utf8_iter" 3583 version = "1.0.4" ··· 3607 source = "registry+https://github.com/rust-lang/crates.io-index" 3608 checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3609 3610 + [[package]] 3611 + name = "valuable" 3612 + version = "0.1.1" 3613 + source = "registry+https://github.com/rust-lang/crates.io-index" 3614 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3615 + 3616 [[package]] 3617 name = "vcpkg" 3618 version = "0.2.15" ··· 3750 "wasm-bindgen", 3751 ] 3752 3753 + [[package]] 3754 + name = "webpki-roots" 3755 + version = "0.25.4" 3756 + source = "registry+https://github.com/rust-lang/crates.io-index" 3757 + checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" 3758 + 3759 [[package]] 3760 name = "widestring" 3761 version = "1.2.0"
+5
Cargo.toml
··· 18 env_logger = "0.11" 19 log = "0.4" 20 reqwest = { version = "0.12", features = ["json"] }
··· 18 env_logger = "0.11" 19 log = "0.4" 20 reqwest = { version = "0.12", features = ["json"] } 21 + rocketman = "0.2.0" 22 + futures-util = "0.3" 23 + anyhow = "1.0" 24 + async-stream = "0.3" 25 + async-trait = "0.1"
+200
src/firehose.rs
···
··· 1 + use anyhow::Result; 2 + use async_trait::async_trait; 3 + use log::{error, info}; 4 + use rocketman::{ 5 + connection::JetstreamConnection, 6 + ingestion::LexiconIngestor, 7 + options::JetstreamOptions, 8 + types::event::{Event, Operation}, 9 + }; 10 + use serde::{Deserialize, Serialize}; 11 + use serde_json::Value; 12 + use std::collections::HashMap; 13 + use std::sync::{Arc, Mutex}; 14 + use tokio::sync::broadcast; 15 + 16 + /// Represents a firehose event that will be sent to the browser 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct FirehoseEvent { 20 + pub did: String, 21 + pub action: String, // "create", "update", or "delete" 22 + pub collection: String, 23 + pub rkey: String, 24 + pub namespace: String, // e.g., "app.bsky" extracted from collection 25 + } 26 + 27 + /// Broadcaster for firehose events 28 + pub type FirehoseBroadcaster = Arc<broadcast::Sender<FirehoseEvent>>; 29 + 30 + /// Manager for DID-specific firehose connections 31 + pub type FirehoseManager = Arc<Mutex<HashMap<String, FirehoseBroadcaster>>>; 32 + 33 + /// A generic ingester that broadcasts all events 34 + struct BroadcastIngester { 35 + broadcaster: FirehoseBroadcaster, 36 + } 37 + 38 + #[async_trait] 39 + impl LexiconIngestor for BroadcastIngester { 40 + async fn ingest(&self, message: Event<Value>) -> Result<()> { 41 + // Only process commit events 42 + let Some(commit) = &message.commit else { 43 + return Ok(()); 44 + }; 45 + 46 + // Extract namespace from collection (e.g., "app.bsky.feed.post" -> "app.bsky") 47 + let collection_parts: Vec<&str> = commit.collection.split('.').collect(); 48 + let namespace = if collection_parts.len() >= 2 { 49 + format!("{}.{}", collection_parts[0], collection_parts[1]) 50 + } else { 51 + commit.collection.clone() 52 + }; 53 + 54 + let action = match commit.operation { 55 + Operation::Create => "create", 56 + Operation::Update => "update", 57 + Operation::Delete => "delete", 58 + }; 59 + 60 + let firehose_event = FirehoseEvent { 61 + did: message.did.clone(), 62 + action: action.to_string(), 63 + collection: commit.collection.clone(), 64 + rkey: commit.rkey.clone(), 65 + namespace: namespace.clone(), 66 + }; 67 + 68 + info!( 69 + "Received event: {} {} {} (namespace: {})", 70 + action, message.did, commit.collection, namespace 71 + ); 72 + 73 + // Broadcast the event (ignore if no receivers) 74 + match self.broadcaster.send(firehose_event) { 75 + Ok(receivers) => { 76 + info!("Broadcast to {} receivers", receivers); 77 + } 78 + Err(_) => { 79 + // No receivers, that's ok 80 + } 81 + } 82 + 83 + Ok(()) 84 + } 85 + } 86 + 87 + /// Create a new FirehoseManager 88 + pub fn create_firehose_manager() -> FirehoseManager { 89 + Arc::new(Mutex::new(HashMap::new())) 90 + } 91 + 92 + /// Get or create a firehose broadcaster for a specific DID 93 + pub async fn get_or_create_broadcaster( 94 + manager: &FirehoseManager, 95 + did: String, 96 + ) -> FirehoseBroadcaster { 97 + // Check if we already have a broadcaster for this DID 98 + { 99 + let broadcasters = manager.lock().unwrap(); 100 + if let Some(broadcaster) = broadcasters.get(&did) { 101 + info!("Reusing existing firehose connection for DID: {}", did); 102 + return broadcaster.clone(); 103 + } 104 + } 105 + 106 + info!("Creating new firehose connection for DID: {}", did); 107 + 108 + // Create a broadcast channel with a buffer of 100 events 109 + let (tx, _rx) = broadcast::channel::<FirehoseEvent>(100); 110 + let broadcaster = Arc::new(tx); 111 + 112 + // Store in manager 113 + { 114 + let mut broadcasters = manager.lock().unwrap(); 115 + broadcasters.insert(did.clone(), broadcaster.clone()); 116 + } 117 + 118 + // Clone for the spawn 119 + let broadcaster_clone = broadcaster.clone(); 120 + let did_clone = did.clone(); 121 + 122 + tokio::spawn(async move { 123 + loop { 124 + info!("Starting Jetstream connection for DID: {}...", did_clone); 125 + 126 + // Configure Jetstream to receive events ONLY for this DID 127 + let opts = JetstreamOptions::builder() 128 + .wanted_dids(vec![did_clone.clone()]) 129 + .build(); 130 + let jetstream = JetstreamConnection::new(opts); 131 + 132 + let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> = 133 + HashMap::new(); 134 + 135 + // Register ingesters for common Bluesky collections 136 + let collections = vec![ 137 + "app.bsky.feed.post", 138 + "app.bsky.feed.like", 139 + "app.bsky.feed.repost", 140 + "app.bsky.graph.follow", 141 + "app.bsky.actor.profile", 142 + ]; 143 + 144 + for collection in collections { 145 + ingesters.insert( 146 + collection.to_string(), 147 + Box::new(BroadcastIngester { 148 + broadcaster: broadcaster_clone.clone(), 149 + }), 150 + ); 151 + } 152 + 153 + // Get channels 154 + let msg_rx = jetstream.get_msg_rx(); 155 + let reconnect_tx = jetstream.get_reconnect_tx(); 156 + 157 + // Cursor for tracking last processed message 158 + let cursor: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None)); 159 + let c_cursor = cursor.clone(); 160 + 161 + // Spawn task to process messages using proper handler 162 + tokio::spawn(async move { 163 + info!("Starting message processing loop for DID-filtered connection"); 164 + while let Ok(message) = msg_rx.recv_async().await { 165 + if let Err(e) = rocketman::handler::handle_message( 166 + message, 167 + &ingesters, 168 + reconnect_tx.clone(), 169 + c_cursor.clone(), 170 + ) 171 + .await 172 + { 173 + error!("Error processing message: {}", e); 174 + } 175 + } 176 + }); 177 + 178 + // Connect to Jetstream 179 + let failed = { 180 + let connect_result = jetstream.connect(cursor).await; 181 + if let Err(e) = connect_result { 182 + error!("Jetstream connection failed for DID {}: {}", did_clone, e); 183 + true 184 + } else { 185 + false 186 + } 187 + }; 188 + 189 + if failed { 190 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 191 + continue; 192 + } 193 + 194 + info!("Jetstream connection dropped for DID: {}, reconnecting in 5 seconds...", did_clone); 195 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 196 + } 197 + }); 198 + 199 + broadcaster 200 + }
+7
src/main.rs
··· 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 use actix_files::Files; 4 5 mod mst; 6 mod oauth; 7 mod routes; ··· 13 14 let client = oauth::create_oauth_client(); 15 16 println!("starting server at http://localhost:8080"); 17 18 HttpServer::new(move || { ··· 31 .build(), 32 ) 33 .app_data(web::Data::new(client.clone())) 34 .service(routes::index) 35 .service(routes::login) 36 .service(routes::callback) ··· 41 .service(routes::init) 42 .service(routes::get_avatar) 43 .service(routes::validate_url) 44 .service(routes::favicon) 45 .service(Files::new("/static", "./static")) 46 })
··· 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 use actix_files::Files; 4 5 + mod firehose; 6 mod mst; 7 mod oauth; 8 mod routes; ··· 14 15 let client = oauth::create_oauth_client(); 16 17 + // Create the firehose manager (connections created lazily per-DID) 18 + let firehose_manager = firehose::create_firehose_manager(); 19 + 20 println!("starting server at http://localhost:8080"); 21 22 HttpServer::new(move || { ··· 35 .build(), 36 ) 37 .app_data(web::Data::new(client.clone())) 38 + .app_data(web::Data::new(firehose_manager.clone())) 39 .service(routes::index) 40 .service(routes::login) 41 .service(routes::callback) ··· 46 .service(routes::init) 47 .service(routes::get_avatar) 48 .service(routes::validate_url) 49 + .service(routes::get_record) 50 + .service(routes::firehose_watch) 51 .service(routes::favicon) 52 .service(Files::new("/static", "./static")) 53 })
+78
src/routes.rs
··· 3 use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 4 use serde::Deserialize; 5 6 use crate::mst; 7 use crate::oauth::OAuthClientType; 8 use crate::templates; ··· 389 390 "valid": is_valid 391 })) 392 }
··· 3 use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 4 use serde::Deserialize; 5 6 + use crate::firehose::FirehoseManager; 7 use crate::mst; 8 use crate::oauth::OAuthClientType; 9 use crate::templates; ··· 390 391 "valid": is_valid 392 })) 393 + } 394 + 395 + #[derive(Deserialize)] 396 + pub struct RecordQuery { 397 + pds: String, 398 + did: String, 399 + collection: String, 400 + rkey: String, 401 + } 402 + 403 + #[get("/api/record")] 404 + pub async fn get_record(query: web::Query<RecordQuery>) -> HttpResponse { 405 + let record_url = format!( 406 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 407 + query.pds, query.did, query.collection, query.rkey 408 + ); 409 + 410 + match reqwest::get(&record_url).await { 411 + Ok(response) => { 412 + if !response.status().is_success() { 413 + return HttpResponse::Ok().json(serde_json::json!({ 414 + "error": "record not found" 415 + })); 416 + } 417 + 418 + match response.json::<serde_json::Value>().await { 419 + Ok(data) => HttpResponse::Ok().json(data), 420 + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 421 + "error": format!("failed to parse record: {}", e) 422 + })), 423 + } 424 + } 425 + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 426 + "error": format!("failed to fetch record: {}", e) 427 + })), 428 + } 429 + } 430 + 431 + #[derive(Deserialize)] 432 + pub struct FirehoseQuery { 433 + did: String, 434 + } 435 + 436 + #[get("/api/firehose/watch")] 437 + pub async fn firehose_watch( 438 + query: web::Query<FirehoseQuery>, 439 + manager: web::Data<FirehoseManager>, 440 + ) -> HttpResponse { 441 + let did = query.did.clone(); 442 + 443 + // Get or create a broadcaster for this DID 444 + let broadcaster = crate::firehose::get_or_create_broadcaster(&manager, did.clone()).await; 445 + let mut rx = broadcaster.subscribe(); 446 + 447 + log::info!("SSE connection established for DID: {}", did); 448 + 449 + let stream = async_stream::stream! { 450 + // Send initial connection message 451 + yield Ok::<_, actix_web::Error>( 452 + web::Bytes::from(format!("data: {{\"type\":\"connected\"}}\n\n")) 453 + ); 454 + 455 + log::info!("Sent initial connection message to client"); 456 + 457 + // Stream firehose events (already filtered by DID at Jetstream level) 458 + while let Ok(event) = rx.recv().await { 459 + log::info!("Sending event to client: {} {} {}", event.action, event.did, event.collection); 460 + let json = serde_json::to_string(&event).unwrap_or_default(); 461 + yield Ok(web::Bytes::from(format!("data: {}\n\n", json))); 462 + } 463 + }; 464 + 465 + HttpResponse::Ok() 466 + .content_type("text/event-stream") 467 + .insert_header(("Cache-Control", "no-cache")) 468 + .insert_header(("X-Accel-Buffering", "no")) 469 + .streaming(Box::pin(stream)) 470 }
+154 -1
src/templates.rs
··· 466 467 468 469 470 471 472 473 ··· 1172 1173 1174 1175 1176 1177 1178 1179 - .ownership-text strong {{ 1180 color: var(--text); 1181 }} 1182 </style> 1183 </head> 1184 <body> 1185 <div class="info" id="infoBtn">?</div> 1186 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 1187 1188 <div class="overlay" id="overlay"></div> 1189 <div class="info-modal" id="infoModal"> 1190 <h2>@me - your repository</h2>
··· 466 467 468 469 + letter-spacing: 0.05em; 470 + }} 471 472 + .identity-pds-label {{ 473 + position: absolute; 474 + bottom: clamp(-1.5rem, -3vmin, -2rem); 475 + font-size: clamp(0.55rem, 1.1vmin, 0.65rem); 476 + color: var(--text-light); 477 + letter-spacing: 0.05em; 478 + font-weight: 500; 479 + }} 480 481 + .identity-avatar {{ 482 + width: clamp(30px, 6vmin, 45px); 483 + height: clamp(30px, 6vmin, 45px); 484 485 486 ··· 1185 1186 1187 1188 + .ownership-text strong {{ 1189 + color: var(--text); 1190 + }} 1191 1192 + .watch-live-btn {{ 1193 + position: fixed; 1194 + top: clamp(1rem, 2vmin, 1.5rem); 1195 + right: clamp(6rem, 14vmin, 9rem); 1196 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1197 + color: var(--text-light); 1198 + border: 1px solid var(--border); 1199 + background: var(--bg); 1200 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1201 + transition: all 0.2s ease; 1202 + z-index: 100; 1203 + cursor: pointer; 1204 + border-radius: 2px; 1205 + display: flex; 1206 + align-items: center; 1207 + gap: 0.5rem; 1208 + }} 1209 1210 + .watch-live-btn:hover {{ 1211 + background: var(--surface); 1212 + color: var(--text); 1213 + border-color: var(--text-light); 1214 + }} 1215 1216 + .watch-live-btn.active {{ 1217 + background: var(--surface-hover); 1218 + color: var(--text); 1219 + border-color: var(--text); 1220 + }} 1221 1222 + .watch-indicator {{ 1223 + width: 8px; 1224 + height: 8px; 1225 + border-radius: 50%; 1226 + background: var(--text-light); 1227 + display: none; 1228 + }} 1229 + 1230 + .watch-live-btn.active .watch-indicator {{ 1231 + display: block; 1232 + animation: pulse 2s ease-in-out infinite; 1233 + }} 1234 + 1235 + @keyframes pulse {{ 1236 + 0%, 100% {{ opacity: 1; }} 1237 + 50% {{ opacity: 0.3; }} 1238 + }} 1239 + 1240 + .firehose-toast {{ 1241 + position: fixed; 1242 + top: clamp(4rem, 8vmin, 5rem); 1243 + right: clamp(1rem, 2vmin, 1.5rem); 1244 + background: var(--surface); 1245 + border: 1px solid var(--border); 1246 + padding: 0.75rem 1rem; 1247 + border-radius: 4px; 1248 + font-size: 0.7rem; 1249 color: var(--text); 1250 + z-index: 200; 1251 + opacity: 0; 1252 + transform: translateY(-10px); 1253 + transition: all 0.3s ease; 1254 + pointer-events: none; 1255 + max-width: 300px; 1256 }} 1257 + 1258 + .firehose-toast.visible {{ 1259 + opacity: 1; 1260 + transform: translateY(0); 1261 + pointer-events: auto; 1262 + }} 1263 + 1264 + .firehose-toast-action {{ 1265 + font-weight: 600; 1266 + color: var(--text); 1267 + }} 1268 + 1269 + .firehose-toast-collection {{ 1270 + color: var(--text-light); 1271 + font-size: 0.65rem; 1272 + margin-top: 0.25rem; 1273 + }} 1274 + 1275 + .firehose-toast-link {{ 1276 + display: inline-block; 1277 + color: var(--text-light); 1278 + font-size: 0.6rem; 1279 + margin-top: 0.5rem; 1280 + text-decoration: none; 1281 + border-bottom: 1px solid transparent; 1282 + transition: all 0.2s ease; 1283 + pointer-events: auto; 1284 + }} 1285 + 1286 + .firehose-toast-link:hover {{ 1287 + color: var(--text); 1288 + border-bottom-color: var(--text); 1289 + }} 1290 + 1291 + @media (max-width: 768px) {{ 1292 + .watch-live-btn {{ 1293 + right: clamp(1rem, 2vmin, 1.5rem); 1294 + top: clamp(4rem, 8vmin, 5rem); 1295 + }} 1296 + 1297 + .firehose-toast {{ 1298 + top: clamp(7rem, 12vmin, 8rem); 1299 + right: clamp(1rem, 2vmin, 1.5rem); 1300 + left: clamp(1rem, 2vmin, 1.5rem); 1301 + max-width: none; 1302 + }} 1303 + }} 1304 </style> 1305 </head> 1306 <body> 1307 <div class="info" id="infoBtn">?</div> 1308 + <button class="watch-live-btn" id="watchLiveBtn"> 1309 + <span class="watch-indicator"></span> 1310 + <span class="watch-label">watch live</span> 1311 + </button> 1312 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 1313 1314 + <div class="firehose-toast" id="firehoseToast"> 1315 + <div class="firehose-toast-action"></div> 1316 + <div class="firehose-toast-collection"></div> 1317 + <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a> 1318 + </div> 1319 + 1320 <div class="overlay" id="overlay"></div> 1321 <div class="info-modal" id="infoModal"> 1322 <h2>@me - your repository</h2> 1323 + 1324 + 1325 + 1326 + 1327 + 1328 + 1329 + 1330 + 1331 + 1332 + 1333 + 1334 + 1335 + 1336 + 1337 + <div class="identity-label">@</div> 1338 + <div class="identity-value" id="handle">loading...</div> 1339 + <div class="identity-hint">tap for details</div> 1340 + <div class="identity-pds-label">Your PDS</div> 1341 + </div> 1342 + <div id="field" class="loading">loading...</div> 1343 + </div>
+384 -2
static/app.js
··· 186 187 188 189 190 191 192 ··· 722 723 724 725 726 727 728 729 730 731 - traverse(tree, 0, padding, width - padding); 732 - return nodes; 733 }
··· 186 187 188 189 + let html = ` 190 + <button class="detail-close" id="detailClose">×</button> 191 + <h3>${namespace}</h3> 192 + <div class="subtitle">records stored in your <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">PDS</a>:</div> 193 + `; 194 195 + if (collections && collections.length > 0) { 196 197 198 ··· 728 729 730 731 + traverse(tree, 0, padding, width - padding); 732 + return nodes; 733 + } 734 735 + // ============================================================================ 736 + // FIREHOSE VISUALIZATION 737 + // ============================================================================ 738 739 + // Particle class for animating firehose events 740 + class FirehoseParticle { 741 + constructor(startX, startY, endX, endY, color, metadata) { 742 + this.x = startX; 743 + this.y = startY; 744 + this.startX = startX; 745 + this.startY = startY; 746 + this.endX = endX; 747 + this.endY = endY; 748 + this.color = color; 749 + this.metadata = metadata; // {action, collection, namespace} 750 + this.progress = 0; 751 + this.speed = 0.012; // Slower for visibility 752 + this.size = 5; 753 + this.glowSize = 10; 754 + } 755 756 + update() { 757 + if (this.progress < 1) { 758 + this.progress += this.speed; 759 + // Cubic ease-in-out 760 + const eased = this.progress < 0.5 761 + ? 4 * this.progress * this.progress * this.progress 762 + : 1 - Math.pow(-2 * this.progress + 2, 3) / 2; 763 + 764 + this.x = this.startX + (this.endX - this.startX) * eased; 765 + this.y = this.startY + (this.endY - this.startY) * eased; 766 + } 767 + return this.progress < 1; 768 + } 769 770 + draw(ctx) { 771 + // Outer glow 772 + ctx.beginPath(); 773 + ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 774 + const gradient = ctx.createRadialGradient( 775 + this.x, this.y, 0, 776 + this.x, this.y, this.glowSize 777 + ); 778 + gradient.addColorStop(0, this.color + '80'); 779 + gradient.addColorStop(1, this.color + '00'); 780 + ctx.fillStyle = gradient; 781 + ctx.fill(); 782 783 + // Inner particle 784 + ctx.beginPath(); 785 + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 786 + ctx.fillStyle = this.color; 787 + ctx.fill(); 788 + } 789 + } 790 791 + // Firehose state 792 + let firehoseParticles = []; 793 + let firehoseCanvas = null; 794 + let firehoseCtx = null; 795 + let firehoseAnimationId = null; 796 + let firehoseEventSource = null; 797 + let isWatchingLive = false; 798 + 799 + function initFirehoseCanvas() { 800 + // Create canvas overlay 801 + firehoseCanvas = document.createElement('canvas'); 802 + firehoseCanvas.id = 'firehoseCanvas'; 803 + firehoseCanvas.style.position = 'fixed'; 804 + firehoseCanvas.style.top = '0'; 805 + firehoseCanvas.style.left = '0'; 806 + firehoseCanvas.style.width = '100%'; 807 + firehoseCanvas.style.height = '100%'; 808 + firehoseCanvas.style.pointerEvents = 'none'; 809 + firehoseCanvas.style.zIndex = '50'; 810 + firehoseCanvas.width = window.innerWidth; 811 + firehoseCanvas.height = window.innerHeight; 812 + 813 + document.body.appendChild(firehoseCanvas); 814 + firehoseCtx = firehoseCanvas.getContext('2d'); 815 + 816 + // Handle window resize 817 + window.addEventListener('resize', () => { 818 + firehoseCanvas.width = window.innerWidth; 819 + firehoseCanvas.height = window.innerHeight; 820 + }); 821 } 822 + 823 + function animateFirehoseParticles() { 824 + if (!firehoseCtx) return; 825 + 826 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 827 + 828 + // Update and draw all particles 829 + firehoseParticles = firehoseParticles.filter(particle => { 830 + const alive = particle.update(); 831 + if (alive) { 832 + particle.draw(firehoseCtx); 833 + } else { 834 + // Particle reached destination - pulse the identity/PDS 835 + pulseIdentity(); 836 + } 837 + return alive; 838 + }); 839 + 840 + if (isWatchingLive) { 841 + firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 842 + } 843 + } 844 + 845 + function pulseIdentity() { 846 + const identity = document.querySelector('.identity'); 847 + if (identity) { 848 + identity.style.transition = 'all 0.3s ease'; 849 + identity.style.transform = 'scale(1.15)'; 850 + identity.style.boxShadow = '0 0 25px rgba(255, 255, 255, 0.6)'; 851 + 852 + setTimeout(() => { 853 + identity.style.transform = ''; 854 + identity.style.boxShadow = ''; 855 + }, 300); 856 + } 857 + } 858 + 859 + async function fetchRecordDetails(pds, did, collection, rkey) { 860 + try { 861 + const response = await fetch( 862 + `/api/record?pds=${encodeURIComponent(pds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` 863 + ); 864 + const data = await response.json(); 865 + if (data.error) return null; 866 + return data.value; 867 + } catch (e) { 868 + console.error('Error fetching record:', e); 869 + return null; 870 + } 871 + } 872 + 873 + function formatToastMessage(action, collection, record) { 874 + const actionText = { 875 + 'create': 'created', 876 + 'update': 'updated', 877 + 'delete': 'deleted' 878 + }[action] || action; 879 + 880 + // If we don't have record details, fall back to basic message 881 + if (!record) { 882 + return { 883 + action: `${actionText} record`, 884 + details: collection 885 + }; 886 + } 887 + 888 + // Format based on collection type 889 + if (collection === 'app.bsky.feed.post') { 890 + const text = record.text || ''; 891 + const preview = text.length > 50 ? text.substring(0, 50) + '...' : text; 892 + return { 893 + action: `${actionText} post`, 894 + details: preview || 'no text' 895 + }; 896 + } else if (collection === 'app.bsky.feed.like') { 897 + return { 898 + action: `${actionText} like`, 899 + details: '' 900 + }; 901 + } else if (collection === 'app.bsky.feed.repost') { 902 + return { 903 + action: `${actionText} repost`, 904 + details: '' 905 + }; 906 + } else if (collection === 'app.bsky.graph.follow') { 907 + return { 908 + action: `${actionText} follow`, 909 + details: '' 910 + }; 911 + } else if (collection === 'app.bsky.actor.profile') { 912 + const displayName = record.displayName || ''; 913 + return { 914 + action: `${actionText} profile`, 915 + details: displayName || 'updated profile' 916 + }; 917 + } 918 + 919 + // Default for unknown collections 920 + return { 921 + action: `${actionText} record`, 922 + details: collection 923 + }; 924 + } 925 + 926 + async function showFirehoseToast(event) { 927 + const toast = document.getElementById('firehoseToast'); 928 + const actionEl = toast.querySelector('.firehose-toast-action'); 929 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 930 + const linkEl = document.getElementById('firehoseToastLink'); 931 + 932 + // Build PDS link for the record 933 + if (globalPds && event.did && event.collection && event.rkey) { 934 + const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 935 + linkEl.href = recordUrl; 936 + } 937 + 938 + // Fetch record details if available (skip for deletes) 939 + let record = null; 940 + if (event.action !== 'delete' && event.rkey && globalPds) { 941 + record = await fetchRecordDetails(globalPds, event.did, event.collection, event.rkey); 942 + } 943 + 944 + const formatted = formatToastMessage(event.action, event.collection, record); 945 + 946 + actionEl.textContent = formatted.action; 947 + collectionEl.textContent = formatted.details; 948 + 949 + toast.classList.add('visible'); 950 + setTimeout(() => { 951 + toast.classList.remove('visible'); 952 + }, 4000); // Slightly longer to read details 953 + } 954 + 955 + function getParticleColor(action) { 956 + const colors = { 957 + 'create': '#4ade80', // green 958 + 'update': '#60a5fa', // blue 959 + 'delete': '#f87171' // red 960 + }; 961 + return colors[action] || '#a0a0a0'; 962 + } 963 + 964 + function createFirehoseParticle(event) { 965 + // Get source app circle position (where the action happened) 966 + const appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 967 + if (!appCircle) return; 968 + 969 + const appRect = appCircle.getBoundingClientRect(); 970 + const startX = appRect.left + appRect.width / 2; 971 + const startY = appRect.top + appRect.height / 2; 972 + 973 + // Get target identity/PDS position (where data is written) 974 + const identity = document.querySelector('.identity'); 975 + if (!identity) return; 976 + 977 + const identityRect = identity.getBoundingClientRect(); 978 + const endX = identityRect.left + identityRect.width / 2; 979 + const endY = identityRect.top + identityRect.height / 2; 980 + 981 + // Create particle (flows from app TO PDS) 982 + const particle = new FirehoseParticle( 983 + startX, startY, 984 + endX, endY, 985 + getParticleColor(event.action), 986 + { 987 + action: event.action, 988 + collection: event.collection, 989 + namespace: event.namespace 990 + } 991 + ); 992 + 993 + firehoseParticles.push(particle); 994 + } 995 + 996 + function connectFirehose() { 997 + console.log('[Firehose] connectFirehose called, did =', did, 'existing connection?', !!firehoseEventSource); 998 + if (!did || firehoseEventSource) { 999 + console.warn('[Firehose] Exiting early - did:', did, 'firehoseEventSource:', firehoseEventSource); 1000 + return; 1001 + } 1002 + 1003 + const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`; 1004 + console.log('[Firehose] Connecting to:', url); 1005 + 1006 + firehoseEventSource = new EventSource(url); 1007 + 1008 + const watchBtn = document.getElementById('watchLiveBtn'); 1009 + const watchLabel = watchBtn.querySelector('.watch-label'); 1010 + 1011 + firehoseEventSource.onopen = () => { 1012 + console.log('Firehose connected'); 1013 + watchLabel.textContent = 'watching...'; 1014 + watchBtn.classList.add('active'); 1015 + }; 1016 + 1017 + firehoseEventSource.onmessage = (e) => { 1018 + try { 1019 + const data = JSON.parse(e.data); 1020 + 1021 + // Skip connection message 1022 + if (data.type === 'connected') { 1023 + console.log('Firehose connection established'); 1024 + return; 1025 + } 1026 + 1027 + console.log('Firehose event:', data); 1028 + 1029 + // Create particle animation 1030 + createFirehoseParticle(data); 1031 + 1032 + // Show toast notification 1033 + showFirehoseToast(data); 1034 + } catch (error) { 1035 + console.error('Error processing firehose message:', error); 1036 + } 1037 + }; 1038 + 1039 + firehoseEventSource.onerror = (error) => { 1040 + console.error('Firehose error:', error); 1041 + watchLabel.textContent = 'connection error'; 1042 + 1043 + // Attempt to reconnect after delay 1044 + if (isWatchingLive) { 1045 + setTimeout(() => { 1046 + if (firehoseEventSource) { 1047 + firehoseEventSource.close(); 1048 + firehoseEventSource = null; 1049 + } 1050 + if (isWatchingLive) { 1051 + watchLabel.textContent = 'reconnecting...'; 1052 + connectFirehose(); 1053 + } 1054 + }, 3000); 1055 + } 1056 + }; 1057 + } 1058 + 1059 + function disconnectFirehose() { 1060 + if (firehoseEventSource) { 1061 + firehoseEventSource.close(); 1062 + firehoseEventSource = null; 1063 + } 1064 + 1065 + if (firehoseAnimationId) { 1066 + cancelAnimationFrame(firehoseAnimationId); 1067 + firehoseAnimationId = null; 1068 + } 1069 + 1070 + firehoseParticles = []; 1071 + if (firehoseCtx) { 1072 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1073 + } 1074 + } 1075 + 1076 + // Toggle watch live 1077 + document.addEventListener('DOMContentLoaded', () => { 1078 + console.log('[Firehose] DOMContentLoaded fired, setting up watch button'); 1079 + const watchBtn = document.getElementById('watchLiveBtn'); 1080 + if (!watchBtn) { 1081 + console.error('[Firehose] Watch button not found!'); 1082 + return; 1083 + } 1084 + 1085 + console.log('[Firehose] Watch button found, attaching click handler'); 1086 + const watchLabel = watchBtn.querySelector('.watch-label'); 1087 + 1088 + watchBtn.addEventListener('click', () => { 1089 + console.log('[Firehose] Watch button clicked! isWatchingLive was:', isWatchingLive); 1090 + isWatchingLive = !isWatchingLive; 1091 + console.log('[Firehose] isWatchingLive now:', isWatchingLive); 1092 + 1093 + if (isWatchingLive) { 1094 + // Start watching 1095 + console.log('[Firehose] Starting watch mode'); 1096 + watchLabel.textContent = 'connecting...'; 1097 + initFirehoseCanvas(); 1098 + connectFirehose(); 1099 + animateFirehoseParticles(); 1100 + } else { 1101 + // Stop watching 1102 + console.log('[Firehose] Stopping watch mode'); 1103 + watchLabel.textContent = 'watch live'; 1104 + watchBtn.classList.remove('active'); 1105 + disconnectFirehose(); 1106 + 1107 + // Clean up canvas 1108 + if (firehoseCanvas) { 1109 + firehoseCanvas.remove(); 1110 + firehoseCanvas = null; 1111 + firehoseCtx = null; 1112 + } 1113 + } 1114 + }); 1115 + });

History

1 round 0 comments
sign up or login to add to the discussion
zzstoatzz.io submitted #0
3 commits
expand
feat: add jetstream firehose backend with SSE streaming
feat: add frontend firehose visualization with particle animations
feat: add app-agnostic record links to firehose toasts
expand 0 comments
closed without merging