+606
-11
Cargo.lock
+606
-11
Cargo.lock
···
62
62
"flate2",
63
63
"foldhash",
64
64
"futures-core",
65
-
"h2",
65
+
"h2 0.3.27",
66
66
"http 0.2.12",
67
67
"httparse",
68
68
"httpdate",
···
276
276
]
277
277
278
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]]
279
291
name = "aho-corasick"
280
292
version = "1.1.3"
281
293
source = "registry+https://github.com/rust-lang/crates.io-index"
···
395
407
]
396
408
397
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]]
398
432
name = "async-trait"
399
433
version = "0.1.89"
400
434
source = "registry+https://github.com/rust-lang/crates.io-index"
···
412
446
"actix-files",
413
447
"actix-session",
414
448
"actix-web",
449
+
"anyhow",
450
+
"async-stream",
451
+
"async-trait",
415
452
"atrium-api",
416
453
"atrium-common",
417
454
"atrium-identity",
418
455
"atrium-oauth",
419
456
"env_logger",
457
+
"futures-util",
420
458
"hickory-resolver",
421
459
"log",
460
+
"reqwest",
461
+
"rocketman",
422
462
"serde",
423
463
"serde_json",
424
464
"tokio",
···
543
583
"miniz_oxide",
544
584
"object",
545
585
"rustc-demangle",
546
-
"windows-link",
586
+
"windows-link 0.2.0",
547
587
]
548
588
549
589
[[package]]
···
576
616
577
617
[[package]]
578
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"
579
625
version = "0.22.1"
580
626
source = "registry+https://github.com/rust-lang/crates.io-index"
581
627
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
···
602
648
]
603
649
604
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]]
605
676
name = "brotli"
606
677
version = "8.0.2"
607
678
source = "registry+https://github.com/rust-lang/crates.io-index"
···
629
700
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
630
701
631
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]]
632
709
name = "bytes"
633
710
version = "1.10.1"
634
711
source = "registry+https://github.com/rust-lang/crates.io-index"
···
672
749
"num-traits",
673
750
"serde",
674
751
"wasm-bindgen",
675
-
"windows-link",
752
+
"windows-link 0.2.0",
676
753
]
677
754
678
755
[[package]]
···
861
938
]
862
939
863
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]]
864
1011
name = "dashmap"
865
1012
version = "6.1.0"
866
1013
source = "registry+https://github.com/rust-lang/crates.io-index"
···
920
1067
]
921
1068
922
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]]
923
1101
name = "derive_more"
924
1102
version = "1.0.0"
925
1103
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1129
1307
]
1130
1308
1131
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]]
1132
1322
name = "fnv"
1133
1323
version = "1.0.7"
1134
1324
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1216
1406
dependencies = [
1217
1407
"futures-core",
1218
1408
"futures-macro",
1409
+
"futures-sink",
1219
1410
"futures-task",
1220
1411
"pin-project-lite",
1221
1412
"pin-utils",
···
1240
1431
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
1241
1432
dependencies = [
1242
1433
"cfg-if",
1434
+
"js-sys",
1243
1435
"libc",
1244
1436
"wasi 0.11.1+wasi-snapshot-preview1",
1437
+
"wasm-bindgen",
1245
1438
]
1246
1439
1247
1440
[[package]]
···
1303
1496
]
1304
1497
1305
1498
[[package]]
1499
+
name = "h2"
1500
+
version = "0.4.12"
1501
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1502
+
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
1503
+
dependencies = [
1504
+
"atomic-waker",
1505
+
"bytes",
1506
+
"fnv",
1507
+
"futures-core",
1508
+
"futures-sink",
1509
+
"http 1.3.1",
1510
+
"indexmap",
1511
+
"slab",
1512
+
"tokio",
1513
+
"tokio-util",
1514
+
"tracing",
1515
+
]
1516
+
1517
+
[[package]]
1306
1518
name = "hashbrown"
1307
1519
version = "0.14.5"
1308
1520
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1467
1679
"bytes",
1468
1680
"futures-channel",
1469
1681
"futures-core",
1682
+
"h2 0.4.12",
1470
1683
"http 1.3.1",
1471
1684
"http-body",
1472
1685
"httparse",
···
1479
1692
]
1480
1693
1481
1694
[[package]]
1695
+
name = "hyper-rustls"
1696
+
version = "0.27.7"
1697
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1698
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
1699
+
dependencies = [
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
+
1710
+
[[package]]
1482
1711
name = "hyper-tls"
1483
1712
version = "0.6.0"
1484
1713
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1513
1742
"percent-encoding",
1514
1743
"pin-project-lite",
1515
1744
"socket2 0.6.0",
1745
+
"system-configuration",
1516
1746
"tokio",
1517
1747
"tower-service",
1518
1748
"tracing",
1749
+
"windows-registry",
1519
1750
]
1520
1751
1521
1752
[[package]]
···
1629
1860
]
1630
1861
1631
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]]
1632
1869
name = "idna"
1633
1870
version = "1.1.0"
1634
1871
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1830
2067
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
1831
2068
1832
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]]
1833
2076
name = "libc"
1834
2077
version = "0.2.176"
1835
2078
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1921
2164
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
1922
2165
1923
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]]
1924
2177
name = "mime"
1925
2178
version = "0.3.17"
1926
2179
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2003
2256
]
2004
2257
2005
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]]
2006
2268
name = "native-tls"
2007
2269
version = "0.2.14"
2008
2270
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2020
2282
]
2021
2283
2022
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]]
2023
2294
name = "num-conv"
2024
2295
version = "0.1.0"
2025
2296
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2143
2414
"libc",
2144
2415
"redox_syscall",
2145
2416
"smallvec",
2146
-
"windows-link",
2417
+
"windows-link 0.2.0",
2147
2418
]
2148
2419
2149
2420
[[package]]
···
2222
2493
]
2223
2494
2224
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]]
2225
2506
name = "primeorder"
2226
2507
version = "0.13.6"
2227
2508
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2366
2647
"async-compression",
2367
2648
"base64 0.22.1",
2368
2649
"bytes",
2650
+
"encoding_rs",
2369
2651
"futures-core",
2370
2652
"futures-util",
2653
+
"h2 0.4.12",
2371
2654
"http 1.3.1",
2372
2655
"http-body",
2373
2656
"http-body-util",
2374
2657
"hyper",
2658
+
"hyper-rustls",
2375
2659
"hyper-tls",
2376
2660
"hyper-util",
2377
2661
"js-sys",
2378
2662
"log",
2663
+
"mime",
2379
2664
"native-tls",
2380
2665
"percent-encoding",
2381
2666
"pin-project-lite",
···
2413
2698
]
2414
2699
2415
2700
[[package]]
2701
+
name = "ring"
2702
+
version = "0.17.14"
2703
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2704
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2705
+
dependencies = [
2706
+
"cc",
2707
+
"cfg-if",
2708
+
"getrandom 0.2.16",
2709
+
"libc",
2710
+
"untrusted",
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]]
2416
2739
name = "rustc-demangle"
2417
2740
version = "0.1.26"
2418
2741
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2441
2764
]
2442
2765
2443
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"
2781
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2782
+
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
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]]
2444
2813
name = "rustls-pki-types"
2445
2814
version = "1.12.0"
2446
2815
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2450
2819
]
2451
2820
2452
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"
2834
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2835
+
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
2836
+
dependencies = [
2837
+
"ring",
2838
+
"rustls-pki-types",
2839
+
"untrusted",
2840
+
]
2841
+
2842
+
[[package]]
2453
2843
name = "rustversion"
2454
2844
version = "1.0.22"
2455
2845
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2475
2865
version = "1.2.0"
2476
2866
source = "registry+https://github.com/rust-lang/crates.io-index"
2477
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
+
]
2478
2878
2479
2879
[[package]]
2480
2880
name = "sec1"
···
2619
3019
]
2620
3020
2621
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]]
2622
3031
name = "shlex"
2623
3032
version = "1.3.0"
2624
3033
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2682
3091
]
2683
3092
2684
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]]
2685
3103
name = "stable_deref_trait"
2686
3104
version = "1.2.0"
2687
3105
source = "registry+https://github.com/rust-lang/crates.io-index"
2688
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"
2689
3113
2690
3114
[[package]]
2691
3115
name = "subtle"
···
2736
3160
]
2737
3161
2738
3162
[[package]]
3163
+
name = "system-configuration"
3164
+
version = "0.6.1"
3165
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3166
+
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
3167
+
dependencies = [
3168
+
"bitflags",
3169
+
"core-foundation",
3170
+
"system-configuration-sys",
3171
+
]
3172
+
3173
+
[[package]]
3174
+
name = "system-configuration-sys"
3175
+
version = "0.6.0"
3176
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3177
+
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
3178
+
dependencies = [
3179
+
"core-foundation-sys",
3180
+
"libc",
3181
+
]
3182
+
3183
+
[[package]]
2739
3184
name = "tagptr"
2740
3185
version = "0.2.0"
2741
3186
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2772
3217
"proc-macro2",
2773
3218
"quote",
2774
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",
2775
3229
]
2776
3230
2777
3231
[[package]]
···
2872
3326
]
2873
3327
2874
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]]
2875
3365
name = "tokio-util"
2876
3366
version = "0.7.16"
2877
3367
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2959
3449
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
2960
3450
dependencies = [
2961
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",
2962
3478
]
2963
3479
2964
3480
[[package]]
···
2979
3495
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2980
3496
2981
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]]
2982
3518
name = "typenum"
2983
3519
version = "1.19.0"
2984
3520
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3017
3553
version = "0.8.0"
3018
3554
source = "registry+https://github.com/rust-lang/crates.io-index"
3019
3555
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
3556
+
3557
+
[[package]]
3558
+
name = "untrusted"
3559
+
version = "0.9.0"
3560
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3561
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3020
3562
3021
3563
[[package]]
3022
3564
name = "url"
···
3031
3573
]
3032
3574
3033
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]]
3034
3582
name = "utf8_iter"
3035
3583
version = "1.0.4"
3036
3584
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3060
3608
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
3061
3609
3062
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]]
3063
3617
name = "vcpkg"
3064
3618
version = "0.2.15"
3065
3619
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3197
3751
]
3198
3752
3199
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]]
3200
3760
name = "widestring"
3201
3761
version = "1.2.0"
3202
3762
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3210
3770
dependencies = [
3211
3771
"windows-implement",
3212
3772
"windows-interface",
3213
-
"windows-link",
3214
-
"windows-result",
3215
-
"windows-strings",
3773
+
"windows-link 0.2.0",
3774
+
"windows-result 0.4.0",
3775
+
"windows-strings 0.5.0",
3216
3776
]
3217
3777
3218
3778
[[package]]
···
3239
3799
3240
3800
[[package]]
3241
3801
name = "windows-link"
3802
+
version = "0.1.3"
3803
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3804
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
3805
+
3806
+
[[package]]
3807
+
name = "windows-link"
3242
3808
version = "0.2.0"
3243
3809
source = "registry+https://github.com/rust-lang/crates.io-index"
3244
3810
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
3245
3811
3246
3812
[[package]]
3813
+
name = "windows-registry"
3814
+
version = "0.5.3"
3815
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3816
+
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
3817
+
dependencies = [
3818
+
"windows-link 0.1.3",
3819
+
"windows-result 0.3.4",
3820
+
"windows-strings 0.4.2",
3821
+
]
3822
+
3823
+
[[package]]
3824
+
name = "windows-result"
3825
+
version = "0.3.4"
3826
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3827
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3828
+
dependencies = [
3829
+
"windows-link 0.1.3",
3830
+
]
3831
+
3832
+
[[package]]
3247
3833
name = "windows-result"
3248
3834
version = "0.4.0"
3249
3835
source = "registry+https://github.com/rust-lang/crates.io-index"
3250
3836
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
3251
3837
dependencies = [
3252
-
"windows-link",
3838
+
"windows-link 0.2.0",
3839
+
]
3840
+
3841
+
[[package]]
3842
+
name = "windows-strings"
3843
+
version = "0.4.2"
3844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3845
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3846
+
dependencies = [
3847
+
"windows-link 0.1.3",
3253
3848
]
3254
3849
3255
3850
[[package]]
···
3258
3853
source = "registry+https://github.com/rust-lang/crates.io-index"
3259
3854
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
3260
3855
dependencies = [
3261
-
"windows-link",
3856
+
"windows-link 0.2.0",
3262
3857
]
3263
3858
3264
3859
[[package]]
···
3303
3898
source = "registry+https://github.com/rust-lang/crates.io-index"
3304
3899
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
3305
3900
dependencies = [
3306
-
"windows-link",
3901
+
"windows-link 0.2.0",
3307
3902
]
3308
3903
3309
3904
[[package]]
···
3343
3938
source = "registry+https://github.com/rust-lang/crates.io-index"
3344
3939
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
3345
3940
dependencies = [
3346
-
"windows-link",
3941
+
"windows-link 0.2.0",
3347
3942
"windows_aarch64_gnullvm 0.53.0",
3348
3943
"windows_aarch64_msvc 0.53.0",
3349
3944
"windows_i686_gnu 0.53.0",
+6
Cargo.toml
+6
Cargo.toml
+200
src/firehose.rs
+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
+
}
+12
src/main.rs
+12
src/main.rs
···
2
2
use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web};
3
3
use actix_files::Files;
4
4
5
+
mod firehose;
6
+
mod mst;
5
7
mod oauth;
6
8
mod routes;
7
9
mod templates;
···
11
13
env_logger::init();
12
14
13
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();
14
19
15
20
println!("starting server at http://localhost:8080");
16
21
···
30
35
.build(),
31
36
)
32
37
.app_data(web::Data::new(client.clone()))
38
+
.app_data(web::Data::new(firehose_manager.clone()))
33
39
.service(routes::index)
34
40
.service(routes::login)
35
41
.service(routes::callback)
36
42
.service(routes::client_metadata)
37
43
.service(routes::logout)
38
44
.service(routes::restore_session)
45
+
.service(routes::get_mst)
46
+
.service(routes::init)
47
+
.service(routes::get_avatar)
48
+
.service(routes::validate_url)
49
+
.service(routes::get_record)
50
+
.service(routes::firehose_watch)
39
51
.service(routes::favicon)
40
52
.service(Files::new("/static", "./static"))
41
53
})
+164
src/mst.rs
+164
src/mst.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use std::collections::HashMap;
3
+
4
+
#[derive(Debug, Serialize, Deserialize, Clone)]
5
+
pub struct Record {
6
+
pub uri: String,
7
+
pub cid: String,
8
+
pub value: serde_json::Value,
9
+
}
10
+
11
+
#[derive(Debug, Serialize, Clone)]
12
+
#[serde(rename_all = "camelCase")]
13
+
pub struct MSTNode {
14
+
pub key: String,
15
+
pub cid: Option<String>,
16
+
pub uri: Option<String>,
17
+
pub value: Option<serde_json::Value>,
18
+
pub depth: i32,
19
+
pub children: Vec<MSTNode>,
20
+
}
21
+
22
+
#[derive(Debug, Serialize)]
23
+
#[serde(rename_all = "camelCase")]
24
+
pub struct MSTResponse {
25
+
pub root: MSTNode,
26
+
pub record_count: usize,
27
+
}
28
+
29
+
pub fn build_mst(records: Vec<Record>) -> MSTResponse {
30
+
let record_count = records.len();
31
+
32
+
// Extract and sort by key
33
+
let mut nodes: Vec<MSTNode> = records
34
+
.into_iter()
35
+
.map(|r| {
36
+
let key = r.uri.split('/').last().unwrap_or("").to_string();
37
+
MSTNode {
38
+
key: key.clone(),
39
+
cid: Some(r.cid),
40
+
uri: Some(r.uri),
41
+
value: Some(r.value),
42
+
depth: calculate_key_depth(&key),
43
+
children: vec![],
44
+
}
45
+
})
46
+
.collect();
47
+
48
+
nodes.sort_by(|a, b| a.key.cmp(&b.key));
49
+
50
+
// Build tree structure
51
+
let root = build_tree(nodes);
52
+
53
+
MSTResponse {
54
+
root,
55
+
record_count,
56
+
}
57
+
}
58
+
59
+
fn calculate_key_depth(key: &str) -> i32 {
60
+
// Simplified depth calculation based on key hash
61
+
let mut hash: i32 = 0;
62
+
for ch in key.chars() {
63
+
hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(ch as i32);
64
+
}
65
+
66
+
// Count leading zero bits (approximation)
67
+
let abs_hash = hash.abs() as u32;
68
+
let binary = format!("{:032b}", abs_hash);
69
+
70
+
let mut depth = 0;
71
+
let chars: Vec<char> = binary.chars().collect();
72
+
let mut i = 0;
73
+
while i < chars.len() - 1 {
74
+
if chars[i] == '0' && chars[i + 1] == '0' {
75
+
depth += 1;
76
+
i += 2;
77
+
} else {
78
+
break;
79
+
}
80
+
}
81
+
82
+
depth.min(5)
83
+
}
84
+
85
+
fn build_tree(nodes: Vec<MSTNode>) -> MSTNode {
86
+
if nodes.is_empty() {
87
+
return MSTNode {
88
+
key: "root".to_string(),
89
+
cid: None,
90
+
uri: None,
91
+
value: None,
92
+
depth: -1,
93
+
children: vec![],
94
+
};
95
+
}
96
+
97
+
// Group by depth
98
+
let mut by_depth: HashMap<i32, Vec<MSTNode>> = HashMap::new();
99
+
for node in nodes {
100
+
by_depth.entry(node.depth).or_insert_with(Vec::new).push(node);
101
+
}
102
+
103
+
let mut depths: Vec<i32> = by_depth.keys().copied().collect();
104
+
depths.sort();
105
+
106
+
// Build tree bottom-up
107
+
let mut current_level: Vec<MSTNode> = by_depth.remove(&depths[depths.len() - 1]).unwrap_or_default();
108
+
109
+
// Work backwards through depths
110
+
for i in (0..depths.len() - 1).rev() {
111
+
let depth = depths[i];
112
+
let mut parent_nodes = by_depth.remove(&depth).unwrap_or_default();
113
+
114
+
// Distribute children to parents
115
+
let children_per_parent = if parent_nodes.is_empty() {
116
+
0
117
+
} else {
118
+
(current_level.len() + parent_nodes.len() - 1) / parent_nodes.len()
119
+
};
120
+
121
+
for (i, parent) in parent_nodes.iter_mut().enumerate() {
122
+
let start = i * children_per_parent;
123
+
let end = ((i + 1) * children_per_parent).min(current_level.len());
124
+
if start < current_level.len() {
125
+
parent.children = current_level.drain(start..end).collect();
126
+
}
127
+
}
128
+
129
+
current_level = parent_nodes;
130
+
}
131
+
132
+
// Create root and attach top-level nodes
133
+
MSTNode {
134
+
key: "root".to_string(),
135
+
cid: None,
136
+
uri: None,
137
+
value: None,
138
+
depth: -1,
139
+
children: current_level,
140
+
}
141
+
}
142
+
143
+
pub async fn fetch_records(pds: &str, did: &str, collection: &str) -> Result<Vec<Record>, String> {
144
+
let url = format!(
145
+
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
146
+
pds, did, collection
147
+
);
148
+
149
+
let response = reqwest::get(&url)
150
+
.await
151
+
.map_err(|e| format!("Failed to fetch records: {}", e))?;
152
+
153
+
#[derive(Deserialize)]
154
+
struct ListRecordsResponse {
155
+
records: Vec<Record>,
156
+
}
157
+
158
+
let data: ListRecordsResponse = response
159
+
.json()
160
+
.await
161
+
.map_err(|e| format!("Failed to parse response: {}", e))?;
162
+
163
+
Ok(data.records)
164
+
}
+317
src/routes.rs
+317
src/routes.rs
···
3
3
use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope};
4
4
use serde::Deserialize;
5
5
6
+
use crate::firehose::FirehoseManager;
7
+
use crate::mst;
6
8
use crate::oauth::OAuthClientType;
7
9
use crate::templates;
8
10
···
151
153
.content_type("image/svg+xml")
152
154
.body(FAVICON_SVG)
153
155
}
156
+
157
+
#[derive(Deserialize)]
158
+
pub struct MSTQuery {
159
+
pds: String,
160
+
did: String,
161
+
collection: String,
162
+
}
163
+
164
+
#[get("/api/mst")]
165
+
pub async fn get_mst(query: web::Query<MSTQuery>) -> HttpResponse {
166
+
match mst::fetch_records(&query.pds, &query.did, &query.collection).await {
167
+
Ok(records) => {
168
+
if records.is_empty() {
169
+
return HttpResponse::Ok().json(serde_json::json!({
170
+
"error": "no records found"
171
+
}));
172
+
}
173
+
174
+
let mst_data = mst::build_mst(records);
175
+
HttpResponse::Ok().json(mst_data)
176
+
}
177
+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
178
+
"error": e
179
+
})),
180
+
}
181
+
}
182
+
183
+
#[derive(Deserialize)]
184
+
pub struct InitQuery {
185
+
did: String,
186
+
}
187
+
188
+
#[derive(serde::Serialize)]
189
+
#[serde(rename_all = "camelCase")]
190
+
pub struct AppInfo {
191
+
namespace: String,
192
+
collections: Vec<String>,
193
+
}
194
+
195
+
#[derive(serde::Serialize)]
196
+
#[serde(rename_all = "camelCase")]
197
+
pub struct InitResponse {
198
+
did: String,
199
+
handle: String,
200
+
pds: String,
201
+
avatar: Option<String>,
202
+
apps: Vec<AppInfo>,
203
+
}
204
+
205
+
#[get("/api/init")]
206
+
pub async fn init(query: web::Query<InitQuery>) -> HttpResponse {
207
+
let did = &query.did;
208
+
209
+
// Fetch DID document
210
+
let did_doc_url = format!("https://plc.directory/{}", did);
211
+
let did_doc_response = match reqwest::get(&did_doc_url).await {
212
+
Ok(r) => r,
213
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
214
+
"error": format!("failed to fetch DID document: {}", e)
215
+
})),
216
+
};
217
+
218
+
let did_doc: serde_json::Value = match did_doc_response.json().await {
219
+
Ok(d) => d,
220
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
221
+
"error": format!("failed to parse DID document: {}", e)
222
+
})),
223
+
};
224
+
225
+
// Extract PDS and handle
226
+
let pds = did_doc["service"]
227
+
.as_array()
228
+
.and_then(|services| {
229
+
services.iter().find(|s| {
230
+
s["type"].as_str() == Some("AtprotoPersonalDataServer")
231
+
})
232
+
})
233
+
.and_then(|s| s["serviceEndpoint"].as_str())
234
+
.unwrap_or("")
235
+
.to_string();
236
+
237
+
let handle = did_doc["alsoKnownAs"]
238
+
.as_array()
239
+
.and_then(|aka| aka.get(0))
240
+
.and_then(|v| v.as_str())
241
+
.map(|s| s.replace("at://", ""))
242
+
.unwrap_or_else(|| did.to_string());
243
+
244
+
// Fetch user avatar from Bluesky
245
+
let avatar = fetch_user_avatar(did).await;
246
+
247
+
// Fetch collections from PDS
248
+
let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did);
249
+
let repo_response = match reqwest::get(&repo_url).await {
250
+
Ok(r) => r,
251
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
252
+
"error": format!("failed to fetch repo: {}", e)
253
+
})),
254
+
};
255
+
256
+
let repo_data: serde_json::Value = match repo_response.json().await {
257
+
Ok(d) => d,
258
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
259
+
"error": format!("failed to parse repo: {}", e)
260
+
})),
261
+
};
262
+
263
+
let collections = repo_data["collections"]
264
+
.as_array()
265
+
.map(|arr| {
266
+
arr.iter()
267
+
.filter_map(|v| v.as_str().map(String::from))
268
+
.collect::<Vec<String>>()
269
+
})
270
+
.unwrap_or_default();
271
+
272
+
// Group by namespace
273
+
let mut apps: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
274
+
for collection in collections {
275
+
let parts: Vec<&str> = collection.split('.').collect();
276
+
if parts.len() >= 2 {
277
+
let namespace = format!("{}.{}", parts[0], parts[1]);
278
+
apps.entry(namespace)
279
+
.or_insert_with(Vec::new)
280
+
.push(collection);
281
+
}
282
+
}
283
+
284
+
let apps_list: Vec<AppInfo> = apps
285
+
.into_iter()
286
+
.map(|(namespace, collections)| AppInfo { namespace, collections })
287
+
.collect();
288
+
289
+
HttpResponse::Ok().json(InitResponse {
290
+
did: did.to_string(),
291
+
handle,
292
+
pds,
293
+
avatar,
294
+
apps: apps_list,
295
+
})
296
+
}
297
+
298
+
async fn fetch_user_avatar(did: &str) -> Option<String> {
299
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
300
+
if let Ok(response) = reqwest::get(&profile_url).await {
301
+
if let Ok(profile) = response.json::<serde_json::Value>().await {
302
+
return profile["avatar"].as_str().map(String::from);
303
+
}
304
+
}
305
+
None
306
+
}
307
+
308
+
#[derive(Deserialize)]
309
+
pub struct AvatarQuery {
310
+
namespace: String,
311
+
}
312
+
313
+
#[get("/api/avatar")]
314
+
pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse {
315
+
let namespace = &query.namespace;
316
+
317
+
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
318
+
let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join(".");
319
+
let handles = vec![
320
+
reversed.clone(),
321
+
format!("{}.bsky.social", reversed),
322
+
];
323
+
324
+
for handle in handles {
325
+
// Try to resolve handle to DID
326
+
let resolve_url = format!("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", handle);
327
+
if let Ok(response) = reqwest::get(&resolve_url).await {
328
+
if let Ok(data) = response.json::<serde_json::Value>().await {
329
+
if let Some(did) = data["did"].as_str() {
330
+
// Try to get profile
331
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
332
+
if let Ok(profile_response) = reqwest::get(&profile_url).await {
333
+
if let Ok(profile) = profile_response.json::<serde_json::Value>().await {
334
+
if let Some(avatar) = profile["avatar"].as_str() {
335
+
return HttpResponse::Ok().json(serde_json::json!({
336
+
"avatarUrl": avatar
337
+
}));
338
+
}
339
+
}
340
+
}
341
+
}
342
+
}
343
+
}
344
+
}
345
+
346
+
HttpResponse::Ok().json(serde_json::json!({
347
+
"avatarUrl": null
348
+
}))
349
+
}
350
+
351
+
#[derive(Deserialize)]
352
+
pub struct ValidateUrlQuery {
353
+
url: String,
354
+
}
355
+
356
+
#[get("/api/validate-url")]
357
+
pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse {
358
+
let url = &query.url;
359
+
360
+
// Build client with redirect following and timeout
361
+
let client = reqwest::Client::builder()
362
+
.timeout(std::time::Duration::from_secs(3))
363
+
.redirect(reqwest::redirect::Policy::limited(5))
364
+
.build()
365
+
.unwrap();
366
+
367
+
// Try HEAD first, fall back to GET if HEAD doesn't succeed
368
+
let is_valid = match client.head(url).send().await {
369
+
Ok(response) => {
370
+
let status = response.status();
371
+
if status.is_success() || status.is_redirection() {
372
+
true
373
+
} else {
374
+
// HEAD returned error status (like 405), try GET
375
+
match client.get(url).send().await {
376
+
Ok(get_response) => get_response.status().is_success(),
377
+
Err(_) => false,
378
+
}
379
+
}
380
+
}
381
+
Err(_) => {
382
+
// HEAD request failed completely, try GET as fallback
383
+
match client.get(url).send().await {
384
+
Ok(response) => response.status().is_success(),
385
+
Err(_) => false,
386
+
}
387
+
}
388
+
};
389
+
390
+
HttpResponse::Ok().json(serde_json::json!({
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
+
}
+370
-1
src/templates.rs
+370
-1
src/templates.rs
···
299
299
-webkit-tap-highlight-color: transparent;
300
300
cursor: pointer;
301
301
border-radius: 2px;
302
+
display: flex;
303
+
align-items: center;
302
304
}}
303
305
304
306
.logout:hover, .logout:active {{
···
469
471
letter-spacing: 0.05em;
470
472
}}
471
473
474
+
.identity-pds-label {{
475
+
position: absolute;
476
+
bottom: clamp(-1.5rem, -3vmin, -2rem);
477
+
font-size: clamp(0.55rem, 1.1vmin, 0.65rem);
478
+
color: var(--text-light);
479
+
letter-spacing: 0.05em;
480
+
font-weight: 500;
481
+
}}
482
+
472
483
.identity-avatar {{
473
484
width: clamp(30px, 6vmin, 45px);
474
485
height: clamp(30px, 6vmin, 45px);
···
525
536
color: var(--text);
526
537
text-align: center;
527
538
max-width: clamp(80px, 15vmin, 120px);
539
+
text-decoration: none;
540
+
display: block;
541
+
}}
542
+
543
+
.app-name:hover {{
544
+
text-decoration: underline;
545
+
color: var(--text);
546
+
}}
547
+
548
+
.app-name.invalid-link {{
549
+
color: var(--text-light);
550
+
opacity: 0.5;
551
+
cursor: not-allowed;
552
+
}}
553
+
554
+
.app-name.invalid-link:hover {{
555
+
text-decoration: none;
556
+
color: var(--text-light);
528
557
}}
529
558
530
559
.detail-panel {{
···
532
561
top: 0;
533
562
left: 0;
534
563
bottom: 0;
535
-
width: 320px;
564
+
width: 500px;
536
565
background: var(--surface);
537
566
border-right: 2px solid var(--border);
538
567
padding: 2.5rem 2rem;
···
541
570
transform: translateX(-100%);
542
571
transition: all 0.25s ease;
543
572
z-index: 1000;
573
+
scrollbar-width: none;
574
+
-ms-overflow-style: none;
575
+
}}
576
+
577
+
.detail-panel::-webkit-scrollbar {{
578
+
display: none;
544
579
}}
545
580
546
581
.detail-panel.visible {{
···
646
681
color: var(--text-light);
647
682
}}
648
683
684
+
.collection-content {{
685
+
margin-top: 0.5rem;
686
+
padding-top: 0.5rem;
687
+
border-top: 1px solid var(--border);
688
+
}}
689
+
690
+
.collection-tabs {{
691
+
display: flex;
692
+
gap: 0;
693
+
margin-bottom: 0.75rem;
694
+
border: 1px solid var(--border);
695
+
border-radius: 2px;
696
+
overflow: hidden;
697
+
}}
698
+
699
+
.collection-tab {{
700
+
flex: 1;
701
+
padding: 0.5rem 0.75rem;
702
+
background: var(--bg);
703
+
border: none;
704
+
border-right: 1px solid var(--border);
705
+
color: var(--text-light);
706
+
font-family: inherit;
707
+
font-size: 0.65rem;
708
+
cursor: pointer;
709
+
transition: all 0.15s ease;
710
+
-webkit-tap-highlight-color: transparent;
711
+
}}
712
+
713
+
.collection-tab:last-child {{
714
+
border-right: none;
715
+
}}
716
+
717
+
.collection-tab:hover {{
718
+
background: var(--surface);
719
+
color: var(--text);
720
+
}}
721
+
722
+
.collection-tab.active {{
723
+
background: var(--surface-hover);
724
+
color: var(--text);
725
+
font-weight: 500;
726
+
}}
727
+
728
+
.collection-view-content {{
729
+
position: relative;
730
+
}}
731
+
732
+
.collection-view {{
733
+
display: none;
734
+
}}
735
+
736
+
.collection-view.active {{
737
+
display: block;
738
+
}}
739
+
740
+
.structure-view {{
741
+
min-height: 600px;
742
+
}}
743
+
744
+
.mst-canvas {{
745
+
width: 100%;
746
+
height: 600px;
747
+
border: 1px solid var(--border);
748
+
border-radius: 4px;
749
+
background: var(--bg);
750
+
margin-top: 0.5rem;
751
+
}}
752
+
753
+
.mst-info {{
754
+
background: var(--bg);
755
+
border: 1px solid var(--border);
756
+
padding: 0.75rem;
757
+
border-radius: 4px;
758
+
margin-bottom: 0.75rem;
759
+
}}
760
+
761
+
.mst-info p {{
762
+
font-size: 0.65rem;
763
+
color: var(--text-lighter);
764
+
line-height: 1.5;
765
+
margin: 0;
766
+
}}
767
+
768
+
.mst-node-modal {{
769
+
position: fixed;
770
+
inset: 0;
771
+
background: rgba(0, 0, 0, 0.75);
772
+
display: flex;
773
+
align-items: center;
774
+
justify-content: center;
775
+
z-index: 3000;
776
+
padding: 1rem;
777
+
}}
778
+
779
+
.mst-node-modal-content {{
780
+
background: var(--surface);
781
+
border: 2px solid var(--border);
782
+
padding: 2rem;
783
+
border-radius: 4px;
784
+
max-width: 600px;
785
+
width: 100%;
786
+
max-height: 80vh;
787
+
overflow-y: auto;
788
+
position: relative;
789
+
}}
790
+
791
+
.mst-node-close {{
792
+
position: absolute;
793
+
top: 1rem;
794
+
right: 1rem;
795
+
width: 32px;
796
+
height: 32px;
797
+
border: 1px solid var(--border);
798
+
background: var(--bg);
799
+
color: var(--text-light);
800
+
cursor: pointer;
801
+
display: flex;
802
+
align-items: center;
803
+
justify-content: center;
804
+
font-size: 1.2rem;
805
+
line-height: 1;
806
+
transition: all 0.2s ease;
807
+
border-radius: 2px;
808
+
}}
809
+
810
+
.mst-node-close:hover {{
811
+
background: var(--surface-hover);
812
+
border-color: var(--text-light);
813
+
color: var(--text);
814
+
}}
815
+
816
+
.mst-node-modal-content h3 {{
817
+
margin-bottom: 1rem;
818
+
font-size: 0.9rem;
819
+
color: var(--text);
820
+
}}
821
+
822
+
.mst-node-info {{
823
+
background: var(--bg);
824
+
border: 1px solid var(--border);
825
+
padding: 0.75rem;
826
+
border-radius: 4px;
827
+
margin-bottom: 1rem;
828
+
}}
829
+
830
+
.mst-node-field {{
831
+
display: flex;
832
+
gap: 0.5rem;
833
+
margin-bottom: 0.5rem;
834
+
font-size: 0.65rem;
835
+
}}
836
+
837
+
.mst-node-field:last-child {{
838
+
margin-bottom: 0;
839
+
}}
840
+
841
+
.mst-node-label {{
842
+
color: var(--text-light);
843
+
font-weight: 500;
844
+
min-width: 40px;
845
+
}}
846
+
847
+
.mst-node-value {{
848
+
color: var(--text);
849
+
word-break: break-all;
850
+
font-family: monospace;
851
+
}}
852
+
853
+
.mst-node-explanation {{
854
+
background: var(--bg);
855
+
border: 1px solid var(--border);
856
+
padding: 0.75rem;
857
+
border-radius: 4px;
858
+
margin-bottom: 1rem;
859
+
}}
860
+
861
+
.mst-node-explanation p {{
862
+
font-size: 0.65rem;
863
+
color: var(--text-lighter);
864
+
line-height: 1.5;
865
+
margin: 0;
866
+
}}
867
+
868
+
.mst-node-data {{
869
+
background: var(--bg);
870
+
border: 1px solid var(--border);
871
+
border-radius: 4px;
872
+
overflow: hidden;
873
+
}}
874
+
875
+
.mst-node-data-header {{
876
+
font-size: 0.65rem;
877
+
color: var(--text-light);
878
+
padding: 0.5rem 0.75rem;
879
+
border-bottom: 1px solid var(--border);
880
+
font-weight: 500;
881
+
}}
882
+
883
+
.mst-node-data pre {{
884
+
margin: 0;
885
+
padding: 0.75rem;
886
+
font-size: 0.625rem;
887
+
color: var(--text);
888
+
white-space: pre-wrap;
889
+
word-break: break-word;
890
+
line-height: 1.5;
891
+
}}
892
+
649
893
.record-list {{
650
894
margin-top: 0.5rem;
651
895
padding-top: 0.5rem;
···
946
1190
.ownership-text strong {{
947
1191
color: var(--text);
948
1192
}}
1193
+
1194
+
.watch-live-btn {{
1195
+
position: fixed;
1196
+
top: clamp(1rem, 2vmin, 1.5rem);
1197
+
right: clamp(6rem, 14vmin, 9rem);
1198
+
font-family: inherit;
1199
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
1200
+
color: var(--text-light);
1201
+
border: 1px solid var(--border);
1202
+
background: var(--bg);
1203
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
1204
+
transition: all 0.2s ease;
1205
+
z-index: 100;
1206
+
cursor: pointer;
1207
+
border-radius: 2px;
1208
+
display: flex;
1209
+
align-items: center;
1210
+
gap: 0.5rem;
1211
+
}}
1212
+
1213
+
.watch-live-btn:hover {{
1214
+
background: var(--surface);
1215
+
color: var(--text);
1216
+
border-color: var(--text-light);
1217
+
}}
1218
+
1219
+
.watch-live-btn.active {{
1220
+
background: var(--surface-hover);
1221
+
color: var(--text);
1222
+
border-color: var(--text);
1223
+
}}
1224
+
1225
+
.watch-indicator {{
1226
+
width: 8px;
1227
+
height: 8px;
1228
+
border-radius: 50%;
1229
+
background: var(--text-light);
1230
+
display: none;
1231
+
}}
1232
+
1233
+
.watch-live-btn.active .watch-indicator {{
1234
+
display: block;
1235
+
animation: pulse 2s ease-in-out infinite;
1236
+
}}
1237
+
1238
+
@keyframes pulse {{
1239
+
0%, 100% {{ opacity: 1; }}
1240
+
50% {{ opacity: 0.3; }}
1241
+
}}
1242
+
1243
+
.firehose-toast {{
1244
+
position: fixed;
1245
+
top: clamp(4rem, 8vmin, 5rem);
1246
+
right: clamp(1rem, 2vmin, 1.5rem);
1247
+
background: var(--surface);
1248
+
border: 1px solid var(--border);
1249
+
padding: 0.75rem 1rem;
1250
+
border-radius: 4px;
1251
+
font-size: 0.7rem;
1252
+
color: var(--text);
1253
+
z-index: 200;
1254
+
opacity: 0;
1255
+
transform: translateY(-10px);
1256
+
transition: all 0.3s ease;
1257
+
pointer-events: none;
1258
+
max-width: 300px;
1259
+
}}
1260
+
1261
+
.firehose-toast.visible {{
1262
+
opacity: 1;
1263
+
transform: translateY(0);
1264
+
pointer-events: auto;
1265
+
}}
1266
+
1267
+
.firehose-toast-action {{
1268
+
font-weight: 600;
1269
+
color: var(--text);
1270
+
}}
1271
+
1272
+
.firehose-toast-collection {{
1273
+
color: var(--text-light);
1274
+
font-size: 0.65rem;
1275
+
margin-top: 0.25rem;
1276
+
}}
1277
+
1278
+
.firehose-toast-link {{
1279
+
display: inline-block;
1280
+
color: var(--text-light);
1281
+
font-size: 0.6rem;
1282
+
margin-top: 0.5rem;
1283
+
text-decoration: none;
1284
+
border-bottom: 1px solid transparent;
1285
+
transition: all 0.2s ease;
1286
+
pointer-events: auto;
1287
+
}}
1288
+
1289
+
.firehose-toast-link:hover {{
1290
+
color: var(--text);
1291
+
border-bottom-color: var(--text);
1292
+
}}
1293
+
1294
+
@media (max-width: 768px) {{
1295
+
.watch-live-btn {{
1296
+
right: clamp(1rem, 2vmin, 1.5rem);
1297
+
top: clamp(4rem, 8vmin, 5rem);
1298
+
}}
1299
+
1300
+
.firehose-toast {{
1301
+
top: clamp(7rem, 12vmin, 8rem);
1302
+
right: clamp(1rem, 2vmin, 1.5rem);
1303
+
left: clamp(1rem, 2vmin, 1.5rem);
1304
+
max-width: none;
1305
+
}}
1306
+
}}
949
1307
</style>
950
1308
</head>
951
1309
<body>
952
1310
<div class="info" id="infoBtn">?</div>
1311
+
<button class="watch-live-btn" id="watchLiveBtn">
1312
+
<span class="watch-indicator"></span>
1313
+
<span class="watch-label">watch live</span>
1314
+
</button>
953
1315
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
1316
+
1317
+
<div class="firehose-toast" id="firehoseToast">
1318
+
<div class="firehose-toast-action"></div>
1319
+
<div class="firehose-toast-collection"></div>
1320
+
<a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a>
1321
+
</div>
954
1322
955
1323
<div class="overlay" id="overlay"></div>
956
1324
<div class="info-modal" id="infoModal">
···
972
1340
<div class="identity-label">@</div>
973
1341
<div class="identity-value" id="handle">loading...</div>
974
1342
<div class="identity-hint">tap for details</div>
1343
+
<div class="identity-pds-label">Your PDS</div>
975
1344
</div>
976
1345
<div id="field" class="loading">loading...</div>
977
1346
</div>
+764
-89
static/app.js
+764
-89
static/app.js
···
5
5
let globalPds = null;
6
6
let globalHandle = null;
7
7
8
-
// Try to fetch app avatar from their bsky profile
8
+
// Fetch app avatar from server
9
9
async function fetchAppAvatar(namespace) {
10
10
try {
11
-
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
12
-
const reversed = namespace.split('.').reverse().join('.');
13
-
// Try reversed domain, then reversed.bsky.social
14
-
const handles = [reversed, `${reversed}.bsky.social`];
15
-
16
-
for (const handle of handles) {
17
-
try {
18
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
19
-
if (!didRes.ok) continue;
20
-
21
-
const { did } = await didRes.json();
22
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
23
-
if (!profileRes.ok) continue;
24
-
25
-
const profile = await profileRes.json();
26
-
if (profile.avatar) {
27
-
return profile.avatar;
28
-
}
29
-
} catch (e) {
30
-
// Silently continue to next handle
31
-
continue;
32
-
}
33
-
}
11
+
const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`);
12
+
const data = await response.json();
13
+
return data.avatarUrl;
34
14
} catch (e) {
35
-
// Expected for namespaces without Bluesky accounts
15
+
return null;
36
16
}
37
-
return null;
38
17
}
39
18
40
19
// Logout handler
···
62
41
detail.classList.remove('visible');
63
42
});
64
43
65
-
// First resolve DID to get PDS endpoint and handle
66
-
fetch('https://plc.directory/' + did)
44
+
// Fetch initialization data from server
45
+
fetch(`/api/init?did=${encodeURIComponent(did)}`)
67
46
.then(r => r.json())
68
-
.then(didDoc => {
69
-
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
70
-
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
71
-
72
-
globalPds = pds;
73
-
globalHandle = handle;
47
+
.then(initData => {
48
+
globalPds = initData.pds;
49
+
globalHandle = initData.handle;
74
50
75
51
// Update identity display with handle
76
-
document.getElementById('handle').textContent = handle;
52
+
document.getElementById('handle').textContent = initData.handle;
77
53
78
-
// Try to fetch and display user's avatar
79
-
fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
80
-
.then(r => r.json())
81
-
.then(profile => {
82
-
if (profile.avatar) {
83
-
const identity = document.querySelector('.identity');
84
-
const avatarImg = document.createElement('img');
85
-
avatarImg.src = profile.avatar;
86
-
avatarImg.className = 'identity-avatar';
87
-
avatarImg.alt = handle;
88
-
// Insert avatar before the @ label
89
-
identity.insertBefore(avatarImg, identity.firstChild);
90
-
}
91
-
})
92
-
.catch(() => {
93
-
// User may not have an avatar set
94
-
});
95
-
96
-
// Store collections and apps for later use
97
-
let allCollections = [];
98
-
let apps = {};
99
-
100
-
// Get all collections from PDS
101
-
return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`);
102
-
})
103
-
.then(r => r.json())
104
-
.then(repo => {
105
-
const collections = repo.collections || [];
106
-
allCollections = collections;
54
+
// Display user's avatar if available
55
+
if (initData.avatar) {
56
+
const identity = document.querySelector('.identity');
57
+
const avatarImg = document.createElement('img');
58
+
avatarImg.src = initData.avatar;
59
+
avatarImg.className = 'identity-avatar';
60
+
avatarImg.alt = initData.handle;
61
+
// Insert avatar before the @ label
62
+
identity.insertBefore(avatarImg, identity.firstChild);
63
+
}
107
64
108
-
// Group by app namespace (first two parts of lexicon)
109
-
apps = {};
110
-
collections.forEach(collection => {
111
-
const parts = collection.split('.');
112
-
if (parts.length >= 2) {
113
-
const namespace = `${parts[0]}.${parts[1]}`;
114
-
if (!apps[namespace]) apps[namespace] = [];
115
-
apps[namespace].push(collection);
116
-
}
65
+
// Convert apps array to object for easier access
66
+
const apps = {};
67
+
const allCollections = [];
68
+
initData.apps.forEach(app => {
69
+
apps[app.namespace] = app.collections;
70
+
allCollections.push(...app.collections);
117
71
});
118
72
119
73
// Add identity click handler now that we have the data
···
196
150
197
151
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
198
152
153
+
// Reverse namespace for display (app.bsky -> bsky.app)
154
+
const displayName = namespace.split('.').reverse().join('.');
155
+
const url = `https://${displayName}`;
156
+
199
157
div.innerHTML = `
200
158
<div class="app-circle" data-namespace="${namespace}">${firstLetter}</div>
201
-
<div class="app-name">${namespace}</div>
159
+
<a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName}</a>
202
160
`;
203
161
204
162
// Try to fetch and display avatar
···
209
167
}
210
168
});
211
169
170
+
// Validate URL
171
+
fetch(`/api/validate-url?url=${encodeURIComponent(url)}`)
172
+
.then(r => r.json())
173
+
.then(data => {
174
+
const link = div.querySelector('.app-name');
175
+
if (!data.valid) {
176
+
link.classList.add('invalid-link');
177
+
link.setAttribute('title', 'this domain is not reachable');
178
+
link.style.pointerEvents = 'none';
179
+
}
180
+
})
181
+
.catch(() => {
182
+
// Silently fail validation check
183
+
});
184
+
212
185
div.addEventListener('click', () => {
213
186
const detail = document.getElementById('detail');
214
187
const collections = apps[namespace];
···
216
189
let html = `
217
190
<button class="detail-close" id="detailClose">ร</button>
218
191
<h3>${namespace}</h3>
219
-
<div class="subtitle">records stored in your pds:</div>
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>
220
193
`;
221
194
222
195
if (collections && collections.length > 0) {
···
307
280
item.addEventListener('click', (e) => {
308
281
e.stopPropagation();
309
282
const lexicon = item.dataset.lexicon;
310
-
const existingRecords = item.querySelector('.record-list');
283
+
const existingContent = item.querySelector('.collection-content');
311
284
312
-
if (existingRecords) {
313
-
existingRecords.remove();
285
+
if (existingContent) {
286
+
existingContent.remove();
314
287
return;
315
288
}
316
289
317
-
const recordListDiv = document.createElement('div');
318
-
recordListDiv.className = 'record-list';
319
-
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
320
-
item.appendChild(recordListDiv);
290
+
// Create container for tabs and content
291
+
const contentDiv = document.createElement('div');
292
+
contentDiv.className = 'collection-content';
321
293
322
-
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`)
294
+
// Will add tabs after we know record count
295
+
contentDiv.innerHTML = `
296
+
<div class="collection-view-content">
297
+
<div class="collection-view records-view active">
298
+
<div class="loading">loading records...</div>
299
+
</div>
300
+
<div class="collection-view structure-view">
301
+
<div class="loading">loading structure...</div>
302
+
</div>
303
+
</div>
304
+
`;
305
+
item.appendChild(contentDiv);
306
+
307
+
const recordsView = contentDiv.querySelector('.records-view');
308
+
const structureView = contentDiv.querySelector('.structure-view');
309
+
310
+
// Load records first to determine if we should show structure tab
311
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`)
323
312
.then(r => r.json())
324
313
.then(data => {
314
+
// Add tabs if there are enough records for structure view
315
+
const hasEnoughRecords = data.records && data.records.length >= 5;
316
+
if (hasEnoughRecords) {
317
+
const tabsHtml = `
318
+
<div class="collection-tabs">
319
+
<button class="collection-tab active" data-tab="records">records</button>
320
+
<button class="collection-tab" data-tab="structure">mst</button>
321
+
</div>
322
+
`;
323
+
contentDiv.insertAdjacentHTML('afterbegin', tabsHtml);
324
+
325
+
// Tab switching logic
326
+
contentDiv.querySelectorAll('.collection-tab').forEach(tab => {
327
+
tab.addEventListener('click', (e) => {
328
+
e.stopPropagation();
329
+
const tabName = tab.dataset.tab;
330
+
331
+
// Update active tab
332
+
contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active'));
333
+
tab.classList.add('active');
334
+
335
+
// Update active view
336
+
contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active'));
337
+
if (tabName === 'records') {
338
+
recordsView.classList.add('active');
339
+
} else if (tabName === 'structure') {
340
+
structureView.classList.add('active');
341
+
// Load structure if not already loaded
342
+
if (structureView.querySelector('.loading')) {
343
+
loadMSTStructure(lexicon, structureView);
344
+
}
345
+
}
346
+
});
347
+
});
348
+
}
349
+
325
350
if (data.records && data.records.length > 0) {
326
351
let recordsHtml = '';
327
352
data.records.forEach((record, idx) => {
···
344
369
recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`;
345
370
}
346
371
347
-
recordListDiv.innerHTML = recordsHtml;
372
+
recordsView.innerHTML = recordsHtml;
348
373
349
374
// Use event delegation for copy and load more buttons
350
-
recordListDiv.addEventListener('click', (e) => {
375
+
recordsView.addEventListener('click', (e) => {
351
376
// Handle copy button
352
377
if (e.target.classList.contains('copy-btn')) {
353
378
e.stopPropagation();
···
401
426
});
402
427
403
428
loadMoreBtn.remove();
404
-
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
429
+
recordsView.insertAdjacentHTML('beforeend', moreHtml);
405
430
406
431
if (moreData.cursor && moreData.records.length === 5) {
407
-
recordListDiv.insertAdjacentHTML('beforeend',
432
+
recordsView.insertAdjacentHTML('beforeend',
408
433
`<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>`
409
434
);
410
435
}
···
412
437
}
413
438
});
414
439
} else {
415
-
recordListDiv.innerHTML = '<div class="record">no records found</div>';
440
+
recordsView.innerHTML = '<div class="record">no records found</div>';
416
441
}
417
442
})
418
443
.catch(e => {
419
444
console.error('Error fetching records:', e);
420
-
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
445
+
recordsView.innerHTML = '<div class="record">error loading records</div>';
421
446
});
422
447
});
423
448
});
···
438
463
document.getElementById('field').innerHTML = 'error loading records';
439
464
console.error(e);
440
465
});
466
+
467
+
// MST Visualization Functions
468
+
async function loadMSTStructure(lexicon, containerView) {
469
+
try {
470
+
// Call server endpoint to build MST
471
+
const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`);
472
+
const data = await response.json();
473
+
474
+
if (data.error) {
475
+
containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`;
476
+
return;
477
+
}
478
+
479
+
const { root, recordCount } = data;
480
+
481
+
// Render structure
482
+
containerView.innerHTML = `
483
+
<div class="mst-info">
484
+
<p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${recordCount} record${recordCount !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p>
485
+
</div>
486
+
<canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas>
487
+
`;
488
+
489
+
// Render tree on canvas
490
+
setTimeout(() => {
491
+
const canvas = containerView.querySelector('.mst-canvas');
492
+
if (canvas) {
493
+
renderMSTTree(canvas, root);
494
+
}
495
+
}, 50);
496
+
497
+
} catch (e) {
498
+
console.error('Error loading MST structure:', e);
499
+
containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>';
500
+
}
501
+
}
502
+
503
+
function renderMSTTree(canvas, tree) {
504
+
const ctx = canvas.getContext('2d');
505
+
const width = canvas.width = canvas.offsetWidth;
506
+
const height = canvas.height = canvas.offsetHeight;
507
+
508
+
// Calculate tree layout
509
+
const layout = layoutTree(tree, width, height);
510
+
511
+
// Get CSS colors
512
+
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim();
513
+
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
514
+
const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim();
515
+
const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim();
516
+
const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim();
517
+
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
518
+
519
+
let hoveredNode = null;
520
+
521
+
function draw() {
522
+
// Clear canvas
523
+
ctx.clearRect(0, 0, width, height);
524
+
525
+
// Draw connections first
526
+
layout.forEach(node => {
527
+
if (node.children) {
528
+
node.children.forEach(child => {
529
+
ctx.beginPath();
530
+
ctx.moveTo(node.x, node.y);
531
+
ctx.lineTo(child.x, child.y);
532
+
ctx.strokeStyle = borderColor;
533
+
ctx.lineWidth = 1;
534
+
ctx.stroke();
535
+
});
536
+
}
537
+
});
538
+
539
+
// Draw nodes
540
+
layout.forEach(node => {
541
+
const isRoot = node.depth === -1;
542
+
const isLeaf = !node.children || node.children.length === 0;
543
+
const isHovered = hoveredNode === node;
544
+
545
+
// Node circle
546
+
ctx.beginPath();
547
+
ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2);
548
+
549
+
ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor;
550
+
ctx.fill();
551
+
552
+
ctx.strokeStyle = isHovered ? textColor : borderColor;
553
+
ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1;
554
+
ctx.stroke();
555
+
});
556
+
557
+
// Draw label for hovered node
558
+
if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') {
559
+
const padding = 6;
560
+
const fontSize = 10;
561
+
ctx.font = `${fontSize}px monospace`;
562
+
const textWidth = ctx.measureText(hoveredNode.key).width;
563
+
564
+
// Position tooltip above node
565
+
const tooltipX = hoveredNode.x;
566
+
const tooltipY = hoveredNode.y - 20;
567
+
const boxWidth = textWidth + padding * 2;
568
+
const boxHeight = fontSize + padding * 2;
569
+
570
+
// Draw tooltip background
571
+
ctx.fillStyle = bgColor;
572
+
ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight);
573
+
574
+
// Draw tooltip border
575
+
ctx.strokeStyle = borderColor;
576
+
ctx.lineWidth = 1;
577
+
ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight);
578
+
579
+
// Draw text
580
+
ctx.fillStyle = textColor;
581
+
ctx.textAlign = 'center';
582
+
ctx.textBaseline = 'middle';
583
+
ctx.fillText(hoveredNode.key, tooltipX, tooltipY);
584
+
}
585
+
}
586
+
587
+
// Mouse move handler
588
+
canvas.addEventListener('mousemove', (e) => {
589
+
const rect = canvas.getBoundingClientRect();
590
+
const mouseX = e.clientX - rect.left;
591
+
const mouseY = e.clientY - rect.top;
592
+
593
+
let foundNode = null;
594
+
for (const node of layout) {
595
+
const isRoot = node.depth === -1;
596
+
const radius = isRoot ? 12 : 8;
597
+
const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2);
598
+
if (dist <= radius) {
599
+
foundNode = node;
600
+
break;
601
+
}
602
+
}
603
+
604
+
if (foundNode !== hoveredNode) {
605
+
hoveredNode = foundNode;
606
+
canvas.style.cursor = hoveredNode ? 'pointer' : 'default';
607
+
draw();
608
+
}
609
+
});
610
+
611
+
// Mouse leave handler
612
+
canvas.addEventListener('mouseleave', () => {
613
+
if (hoveredNode) {
614
+
hoveredNode = null;
615
+
canvas.style.cursor = 'default';
616
+
draw();
617
+
}
618
+
});
619
+
620
+
// Click handler
621
+
canvas.addEventListener('click', (e) => {
622
+
if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') {
623
+
showNodeModal(hoveredNode);
624
+
}
625
+
});
626
+
627
+
// Initial draw
628
+
draw();
629
+
}
630
+
631
+
function showNodeModal(node) {
632
+
// Create modal
633
+
const modal = document.createElement('div');
634
+
modal.className = 'mst-node-modal';
635
+
modal.innerHTML = `
636
+
<div class="mst-node-modal-content">
637
+
<button class="mst-node-close">ร</button>
638
+
<h3>record in MST</h3>
639
+
<div class="mst-node-info">
640
+
<div class="mst-node-field">
641
+
<span class="mst-node-label">TID:</span>
642
+
<span class="mst-node-value">${node.key}</span>
643
+
</div>
644
+
<div class="mst-node-field">
645
+
<span class="mst-node-label">CID:</span>
646
+
<span class="mst-node-value">${node.cid}</span>
647
+
</div>
648
+
${node.uri ? `
649
+
<div class="mst-node-field">
650
+
<span class="mst-node-label">URI:</span>
651
+
<span class="mst-node-value">${node.uri}</span>
652
+
</div>
653
+
` : ''}
654
+
</div>
655
+
<div class="mst-node-explanation">
656
+
<p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p>
657
+
</div>
658
+
${node.value ? `
659
+
<div class="mst-node-data">
660
+
<div class="mst-node-data-header">record data</div>
661
+
<pre>${JSON.stringify(node.value, null, 2)}</pre>
662
+
</div>
663
+
` : ''}
664
+
</div>
665
+
`;
666
+
667
+
// Add to DOM
668
+
document.body.appendChild(modal);
669
+
670
+
// Close handlers
671
+
modal.querySelector('.mst-node-close').addEventListener('click', () => {
672
+
modal.remove();
673
+
});
674
+
675
+
modal.addEventListener('click', (e) => {
676
+
if (e.target === modal) {
677
+
modal.remove();
678
+
}
679
+
});
680
+
}
681
+
682
+
function layoutTree(tree, width, height) {
683
+
const nodes = [];
684
+
const padding = 40;
685
+
const availableWidth = width - padding * 2;
686
+
const availableHeight = height - padding * 2;
687
+
688
+
// Calculate max depth and total nodes at each depth
689
+
const depthCounts = {};
690
+
function countDepths(node, depth) {
691
+
if (!depthCounts[depth]) depthCounts[depth] = 0;
692
+
depthCounts[depth]++;
693
+
if (node.children) {
694
+
node.children.forEach(child => countDepths(child, depth + 1));
695
+
}
696
+
}
697
+
countDepths(tree, 0);
698
+
699
+
const maxDepth = Math.max(...Object.keys(depthCounts).map(Number));
700
+
const verticalSpacing = availableHeight / (maxDepth + 1);
701
+
702
+
// Track positions at each depth to avoid overlap
703
+
const positionsByDepth = {};
704
+
705
+
function traverse(node, depth, minX, maxX) {
706
+
if (!positionsByDepth[depth]) positionsByDepth[depth] = [];
707
+
708
+
// Calculate position based on available space
709
+
const x = (minX + maxX) / 2;
710
+
const y = padding + verticalSpacing * depth;
711
+
712
+
const layoutNode = { ...node, x, y };
713
+
nodes.push(layoutNode);
714
+
positionsByDepth[depth].push(x);
715
+
716
+
if (node.children && node.children.length > 0) {
717
+
layoutNode.children = [];
718
+
const childWidth = (maxX - minX) / node.children.length;
719
+
720
+
node.children.forEach((child, idx) => {
721
+
const childMinX = minX + childWidth * idx;
722
+
const childMaxX = minX + childWidth * (idx + 1);
723
+
const childLayout = traverse(child, depth + 1, childMinX, childMaxX);
724
+
layoutNode.children.push(childLayout);
725
+
});
726
+
}
727
+
728
+
return layoutNode;
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
+
});