Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

Merge pull request #34 from at-microcosm/spacedust

spacedust link firehose

authored by bad-example.com and committed by GitHub 513d6b90 ff0003c1

+272 -40
Cargo.lock
··· 276 276 checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 277 277 278 278 [[package]] 279 + name = "aws-lc-rs" 280 + version = "1.13.1" 281 + source = "registry+https://github.com/rust-lang/crates.io-index" 282 + checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" 283 + dependencies = [ 284 + "aws-lc-sys", 285 + "zeroize", 286 + ] 287 + 288 + [[package]] 289 + name = "aws-lc-sys" 290 + version = "0.29.0" 291 + source = "registry+https://github.com/rust-lang/crates.io-index" 292 + checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" 293 + dependencies = [ 294 + "bindgen 0.69.5", 295 + "cc", 296 + "cmake", 297 + "dunce", 298 + "fs_extra", 299 + ] 300 + 301 + [[package]] 279 302 name = "axum" 280 303 version = "0.8.3" 281 304 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 450 473 "itertools 0.12.1", 451 474 "lazy_static", 452 475 "lazycell", 476 + "log", 477 + "prettyplease", 453 478 "proc-macro2", 454 479 "quote", 455 480 "regex", 456 481 "rustc-hash 1.1.0", 457 482 "shlex", 458 483 "syn", 484 + "which", 459 485 ] 460 486 461 487 [[package]] ··· 654 680 655 681 [[package]] 656 682 name = "clap" 657 - version = "4.5.35" 683 + version = "4.5.40" 658 684 source = "registry+https://github.com/rust-lang/crates.io-index" 659 - checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" 685 + checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" 660 686 dependencies = [ 661 687 "clap_builder", 662 688 "clap_derive", ··· 664 690 665 691 [[package]] 666 692 name = "clap_builder" 667 - version = "4.5.35" 693 + version = "4.5.40" 668 694 source = "registry+https://github.com/rust-lang/crates.io-index" 669 - checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" 695 + checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" 670 696 dependencies = [ 671 697 "anstream", 672 698 "anstyle", ··· 676 702 677 703 [[package]] 678 704 name = "clap_derive" 679 - version = "4.5.32" 705 + version = "4.5.40" 680 706 source = "registry+https://github.com/rust-lang/crates.io-index" 681 - checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 707 + checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" 682 708 dependencies = [ 683 709 "heck", 684 710 "proc-macro2", ··· 704 730 ] 705 731 706 732 [[package]] 733 + name = "cmake" 734 + version = "0.1.54" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" 737 + dependencies = [ 738 + "cc", 739 + ] 740 + 741 + [[package]] 707 742 name = "colorchoice" 708 743 version = "1.0.3" 709 744 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 755 790 "tokio", 756 791 "tokio-util", 757 792 "tower-http", 758 - "tungstenite", 793 + "tungstenite 0.26.2", 759 794 "zstd", 760 795 ] 761 796 ··· 764 799 version = "0.9.4" 765 800 source = "registry+https://github.com/rust-lang/crates.io-index" 766 801 checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 802 + dependencies = [ 803 + "core-foundation-sys", 804 + "libc", 805 + ] 806 + 807 + [[package]] 808 + name = "core-foundation" 809 + version = "0.10.1" 810 + source = "registry+https://github.com/rust-lang/crates.io-index" 811 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 767 812 dependencies = [ 768 813 "core-foundation-sys", 769 814 "libc", ··· 839 884 840 885 [[package]] 841 886 name = "ctrlc" 842 - version = "3.4.6" 887 + version = "3.4.7" 843 888 source = "registry+https://github.com/rust-lang/crates.io-index" 844 - checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" 889 + checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" 845 890 dependencies = [ 846 891 "nix", 847 892 "windows-sys 0.59.0", ··· 988 1033 989 1034 [[package]] 990 1035 name = "dropshot" 991 - version = "0.16.0" 1036 + version = "0.16.2" 992 1037 source = "registry+https://github.com/rust-lang/crates.io-index" 993 - checksum = "a37c505dad56e0c1fa5ed47e29fab1a1ab2d1a9d93e952024bb47168969705f6" 1038 + checksum = "50e8fed669e35e757646ad10f97c4d26dd22cce3da689b307954f7000d2719d0" 994 1039 dependencies = [ 995 1040 "async-stream", 996 1041 "async-trait", ··· 1012 1057 "openapiv3", 1013 1058 "paste", 1014 1059 "percent-encoding", 1015 - "rustls", 1060 + "rustls 0.22.4", 1016 1061 "rustls-pemfile", 1017 1062 "schemars", 1018 1063 "scopeguard", ··· 1029 1074 "slog-term", 1030 1075 "thiserror 2.0.12", 1031 1076 "tokio", 1032 - "tokio-rustls", 1077 + "tokio-rustls 0.25.0", 1033 1078 "toml", 1034 1079 "uuid", 1035 1080 "version_check", ··· 1038 1083 1039 1084 [[package]] 1040 1085 name = "dropshot_endpoint" 1041 - version = "0.16.0" 1086 + version = "0.16.2" 1042 1087 source = "registry+https://github.com/rust-lang/crates.io-index" 1043 - checksum = "8b1a6db3728f0195e3ad62807649913aaba06d45421e883416e555e51464ef67" 1088 + checksum = "acebb687581abdeaa2c89fa448818a5f803b0e68e5d7e7a1cf585a8f3c5c57ac" 1044 1089 dependencies = [ 1045 1090 "heck", 1046 1091 "proc-macro2", ··· 1052 1097 ] 1053 1098 1054 1099 [[package]] 1100 + name = "dunce" 1101 + version = "1.0.5" 1102 + source = "registry+https://github.com/rust-lang/crates.io-index" 1103 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 1104 + 1105 + [[package]] 1055 1106 name = "dyn-clone" 1056 1107 version = "1.0.19" 1057 1108 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1231 1282 "rustix 0.38.44", 1232 1283 "windows-sys 0.52.0", 1233 1284 ] 1285 + 1286 + [[package]] 1287 + name = "fs_extra" 1288 + version = "1.3.0" 1289 + source = "registry+https://github.com/rust-lang/crates.io-index" 1290 + checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 1234 1291 1235 1292 [[package]] 1236 1293 name = "futures" ··· 1481 1538 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1482 1539 1483 1540 [[package]] 1541 + name = "home" 1542 + version = "0.5.11" 1543 + source = "registry+https://github.com/rust-lang/crates.io-index" 1544 + checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 1545 + dependencies = [ 1546 + "windows-sys 0.59.0", 1547 + ] 1548 + 1549 + [[package]] 1484 1550 name = "hostname" 1485 1551 version = "0.3.1" 1486 1552 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1579 1645 ] 1580 1646 1581 1647 [[package]] 1648 + name = "hyper-rustls" 1649 + version = "0.27.7" 1650 + source = "registry+https://github.com/rust-lang/crates.io-index" 1651 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1652 + dependencies = [ 1653 + "http", 1654 + "hyper", 1655 + "hyper-util", 1656 + "rustls 0.23.28", 1657 + "rustls-native-certs", 1658 + "rustls-pki-types", 1659 + "tokio", 1660 + "tokio-rustls 0.26.2", 1661 + "tower-service", 1662 + ] 1663 + 1664 + [[package]] 1582 1665 name = "hyper-util" 1583 1666 version = "0.1.11" 1584 1667 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1872 1955 "serde_json", 1873 1956 "thiserror 2.0.12", 1874 1957 "tokio", 1875 - "tokio-tungstenite", 1958 + "tokio-tungstenite 0.26.2", 1876 1959 "url", 1877 1960 "zstd", 1878 1961 ] ··· 2202 2285 2203 2286 [[package]] 2204 2287 name = "metrics-exporter-prometheus" 2205 - version = "0.17.0" 2288 + version = "0.17.1" 2206 2289 source = "registry+https://github.com/rust-lang/crates.io-index" 2207 - checksum = "df88858cd28baaaf2cfc894e37789ed4184be0e1351157aec7bf3c2266c793fd" 2290 + checksum = "989903b4c7abfa6827a8d1128ef42faf83f8969d429797c5431f236f2cae8b8b" 2208 2291 dependencies = [ 2209 2292 "base64 0.22.1", 2210 2293 "http-body-util", 2211 2294 "hyper", 2295 + "hyper-rustls", 2212 2296 "hyper-util", 2213 2297 "indexmap 2.9.0", 2214 2298 "ipnet", ··· 2367 2451 "openssl-probe", 2368 2452 "openssl-sys", 2369 2453 "schannel", 2370 - "security-framework", 2454 + "security-framework 2.11.1", 2371 2455 "security-framework-sys", 2372 2456 "tempfile", 2373 2457 ] 2374 2458 2375 2459 [[package]] 2376 2460 name = "nix" 2377 - version = "0.29.0" 2461 + version = "0.30.1" 2378 2462 source = "registry+https://github.com/rust-lang/crates.io-index" 2379 - checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" 2463 + checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" 2380 2464 dependencies = [ 2381 2465 "bitflags", 2382 2466 "cfg-if", ··· 2632 2716 ] 2633 2717 2634 2718 [[package]] 2719 + name = "prettyplease" 2720 + version = "0.2.34" 2721 + source = "registry+https://github.com/rust-lang/crates.io-index" 2722 + checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" 2723 + dependencies = [ 2724 + "proc-macro2", 2725 + "syn", 2726 + ] 2727 + 2728 + [[package]] 2635 2729 name = "proc-macro2" 2636 2730 version = "1.0.94" 2637 2731 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2969 3063 "log", 2970 3064 "ring", 2971 3065 "rustls-pki-types", 2972 - "rustls-webpki", 3066 + "rustls-webpki 0.102.8", 3067 + "subtle", 3068 + "zeroize", 3069 + ] 3070 + 3071 + [[package]] 3072 + name = "rustls" 3073 + version = "0.23.28" 3074 + source = "registry+https://github.com/rust-lang/crates.io-index" 3075 + checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" 3076 + dependencies = [ 3077 + "aws-lc-rs", 3078 + "once_cell", 3079 + "rustls-pki-types", 3080 + "rustls-webpki 0.103.3", 2973 3081 "subtle", 2974 3082 "zeroize", 2975 3083 ] 2976 3084 2977 3085 [[package]] 3086 + name = "rustls-native-certs" 3087 + version = "0.8.1" 3088 + source = "registry+https://github.com/rust-lang/crates.io-index" 3089 + checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" 3090 + dependencies = [ 3091 + "openssl-probe", 3092 + "rustls-pki-types", 3093 + "schannel", 3094 + "security-framework 3.2.0", 3095 + ] 3096 + 3097 + [[package]] 2978 3098 name = "rustls-pemfile" 2979 3099 version = "2.2.0" 2980 3100 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2995 3115 source = "registry+https://github.com/rust-lang/crates.io-index" 2996 3116 checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" 2997 3117 dependencies = [ 3118 + "ring", 3119 + "rustls-pki-types", 3120 + "untrusted", 3121 + ] 3122 + 3123 + [[package]] 3124 + name = "rustls-webpki" 3125 + version = "0.103.3" 3126 + source = "registry+https://github.com/rust-lang/crates.io-index" 3127 + checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 3128 + dependencies = [ 3129 + "aws-lc-rs", 2998 3130 "ring", 2999 3131 "rustls-pki-types", 3000 3132 "untrusted", ··· 3066 3198 checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 3067 3199 dependencies = [ 3068 3200 "bitflags", 3069 - "core-foundation", 3201 + "core-foundation 0.9.4", 3202 + "core-foundation-sys", 3203 + "libc", 3204 + "security-framework-sys", 3205 + ] 3206 + 3207 + [[package]] 3208 + name = "security-framework" 3209 + version = "3.2.0" 3210 + source = "registry+https://github.com/rust-lang/crates.io-index" 3211 + checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" 3212 + dependencies = [ 3213 + "bitflags", 3214 + "core-foundation 0.10.1", 3070 3215 "core-foundation-sys", 3071 3216 "libc", 3072 3217 "security-framework-sys", ··· 3184 3329 3185 3330 [[package]] 3186 3331 name = "serde_spanned" 3187 - version = "0.6.8" 3332 + version = "0.6.9" 3188 3333 source = "registry+https://github.com/rust-lang/crates.io-index" 3189 - checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 3334 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 3190 3335 dependencies = [ 3191 3336 "serde", 3192 3337 ] ··· 3378 3523 ] 3379 3524 3380 3525 [[package]] 3526 + name = "spacedust" 3527 + version = "0.1.0" 3528 + dependencies = [ 3529 + "async-trait", 3530 + "clap", 3531 + "ctrlc", 3532 + "dropshot", 3533 + "env_logger", 3534 + "futures", 3535 + "http", 3536 + "jetstream", 3537 + "links", 3538 + "log", 3539 + "metrics", 3540 + "metrics-exporter-prometheus 0.17.1", 3541 + "rand 0.9.1", 3542 + "schemars", 3543 + "semver", 3544 + "serde", 3545 + "serde_json", 3546 + "serde_qs", 3547 + "thiserror 2.0.12", 3548 + "tinyjson", 3549 + "tokio", 3550 + "tokio-tungstenite 0.27.0", 3551 + "tokio-util", 3552 + ] 3553 + 3554 + [[package]] 3381 3555 name = "spin" 3382 3556 version = "0.9.8" 3383 3557 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3412 3586 3413 3587 [[package]] 3414 3588 name = "syn" 3415 - version = "2.0.100" 3589 + version = "2.0.103" 3416 3590 source = "registry+https://github.com/rust-lang/crates.io-index" 3417 - checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 3591 + checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" 3418 3592 dependencies = [ 3419 3593 "proc-macro2", 3420 3594 "quote", ··· 3595 3769 3596 3770 [[package]] 3597 3771 name = "tokio" 3598 - version = "1.44.2" 3772 + version = "1.45.1" 3599 3773 source = "registry+https://github.com/rust-lang/crates.io-index" 3600 - checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" 3774 + checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" 3601 3775 dependencies = [ 3602 3776 "backtrace", 3603 3777 "bytes", ··· 3638 3812 source = "registry+https://github.com/rust-lang/crates.io-index" 3639 3813 checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" 3640 3814 dependencies = [ 3641 - "rustls", 3815 + "rustls 0.22.4", 3642 3816 "rustls-pki-types", 3643 3817 "tokio", 3644 3818 ] 3645 3819 3646 3820 [[package]] 3821 + name = "tokio-rustls" 3822 + version = "0.26.2" 3823 + source = "registry+https://github.com/rust-lang/crates.io-index" 3824 + checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3825 + dependencies = [ 3826 + "rustls 0.23.28", 3827 + "tokio", 3828 + ] 3829 + 3830 + [[package]] 3647 3831 name = "tokio-tungstenite" 3648 3832 version = "0.26.2" 3649 3833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3654 3838 "native-tls", 3655 3839 "tokio", 3656 3840 "tokio-native-tls", 3657 - "tungstenite", 3841 + "tungstenite 0.26.2", 3842 + ] 3843 + 3844 + [[package]] 3845 + name = "tokio-tungstenite" 3846 + version = "0.27.0" 3847 + source = "registry+https://github.com/rust-lang/crates.io-index" 3848 + checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" 3849 + dependencies = [ 3850 + "futures-util", 3851 + "log", 3852 + "tokio", 3853 + "tungstenite 0.27.0", 3658 3854 ] 3659 3855 3660 3856 [[package]] ··· 3672 3868 3673 3869 [[package]] 3674 3870 name = "toml" 3675 - version = "0.8.20" 3871 + version = "0.8.23" 3676 3872 source = "registry+https://github.com/rust-lang/crates.io-index" 3677 - checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" 3873 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 3678 3874 dependencies = [ 3679 3875 "serde", 3680 3876 "serde_spanned", ··· 3684 3880 3685 3881 [[package]] 3686 3882 name = "toml_datetime" 3687 - version = "0.6.8" 3883 + version = "0.6.11" 3688 3884 source = "registry+https://github.com/rust-lang/crates.io-index" 3689 - checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 3885 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 3690 3886 dependencies = [ 3691 3887 "serde", 3692 3888 ] 3693 3889 3694 3890 [[package]] 3695 3891 name = "toml_edit" 3696 - version = "0.22.24" 3892 + version = "0.22.27" 3697 3893 source = "registry+https://github.com/rust-lang/crates.io-index" 3698 - checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" 3894 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 3699 3895 dependencies = [ 3700 3896 "indexmap 2.9.0", 3701 3897 "serde", 3702 3898 "serde_spanned", 3703 3899 "toml_datetime", 3900 + "toml_write", 3704 3901 "winnow", 3705 3902 ] 3706 3903 3707 3904 [[package]] 3905 + name = "toml_write" 3906 + version = "0.1.2" 3907 + source = "registry+https://github.com/rust-lang/crates.io-index" 3908 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 3909 + 3910 + [[package]] 3708 3911 name = "tower" 3709 3912 version = "0.5.2" 3710 3913 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3833 4036 ] 3834 4037 3835 4038 [[package]] 4039 + name = "tungstenite" 4040 + version = "0.27.0" 4041 + source = "registry+https://github.com/rust-lang/crates.io-index" 4042 + checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" 4043 + dependencies = [ 4044 + "bytes", 4045 + "data-encoding", 4046 + "http", 4047 + "httparse", 4048 + "log", 4049 + "rand 0.9.1", 4050 + "sha1", 4051 + "thiserror 2.0.12", 4052 + "utf-8", 4053 + ] 4054 + 4055 + [[package]] 3836 4056 name = "typenum" 3837 4057 version = "1.18.0" 3838 4058 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3858 4078 "log", 3859 4079 "lsm-tree", 3860 4080 "metrics", 3861 - "metrics-exporter-prometheus 0.17.0", 4081 + "metrics-exporter-prometheus 0.17.1", 3862 4082 "schemars", 3863 4083 "semver", 3864 4084 "serde", ··· 4118 4338 ] 4119 4339 4120 4340 [[package]] 4341 + name = "which" 4342 + version = "4.4.2" 4343 + source = "registry+https://github.com/rust-lang/crates.io-index" 4344 + checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 4345 + dependencies = [ 4346 + "either", 4347 + "home", 4348 + "once_cell", 4349 + "rustix 0.38.44", 4350 + ] 4351 + 4352 + [[package]] 4121 4353 name = "winapi" 4122 4354 version = "0.3.9" 4123 4355 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4346 4578 4347 4579 [[package]] 4348 4580 name = "winnow" 4349 - version = "0.7.6" 4581 + version = "0.7.11" 4350 4582 source = "registry+https://github.com/rust-lang/crates.io-index" 4351 - checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" 4583 + checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" 4352 4584 dependencies = [ 4353 4585 "memchr", 4354 4586 ]
+1
Cargo.toml
··· 6 6 "jetstream", 7 7 "ufos", 8 8 "ufos/fuzz", 9 + "spacedust", 9 10 ]
+2 -1
jetstream/src/error.rs
··· 38 38 pub enum JetstreamEventError { 39 39 #[error("failed to load built-in zstd dictionary for decoding: {0}")] 40 40 CompressionDictionaryError(io::Error), 41 - 42 41 #[error("failed to send ping or pong: {0}")] 43 42 PingPongError(#[from] tokio_tungstenite::tungstenite::Error), 43 + #[error("no messages received within ttl")] 44 + NoMessagesReceived, 44 45 #[error("jetstream event receiver closed")] 45 46 ReceiverClosedError, 46 47 }
+29 -4
jetstream/src/lib.rs
··· 27 27 Receiver, 28 28 Sender, 29 29 }, 30 + time::timeout, 30 31 }; 31 32 use tokio_tungstenite::{ 32 33 connect_async, ··· 200 201 /// can help prevent that if your consumer sometimes pauses, at a cost of higher memory 201 202 /// usage while events are buffered. 202 203 pub channel_size: usize, 204 + /// How long since the last jetstream message before we consider the connection dead 205 + /// 206 + /// Default: 15s 207 + pub liveliness_ttl: Duration, 203 208 } 204 209 205 210 impl Default for JetstreamConfig { ··· 213 218 omit_user_agent_jetstream_info: false, 214 219 replay_on_reconnect: false, 215 220 channel_size: 4096, // a few seconds of firehose buffer 221 + liveliness_ttl: Duration::from_secs(15), 216 222 } 217 223 } 218 224 } ··· 380 386 381 387 let (send_channel, receive_channel) = channel(self.config.channel_size); 382 388 let replay_on_reconnect = self.config.replay_on_reconnect; 389 + let liveliness_ttl = self.config.liveliness_ttl; 383 390 let build_request = self.config.get_request_builder(); 384 391 385 392 tokio::task::spawn(async move { ··· 414 421 if let Ok((ws_stream, _)) = connect_async(req).await { 415 422 let t_connected = Instant::now(); 416 423 log::info!("jetstream connected. starting websocket task..."); 417 - if let Err(e) = 418 - websocket_task(dict, ws_stream, send_channel.clone(), &mut last_cursor) 419 - .await 424 + if let Err(e) = websocket_task( 425 + dict, 426 + ws_stream, 427 + send_channel.clone(), 428 + &mut last_cursor, 429 + liveliness_ttl, 430 + ) 431 + .await 420 432 { 421 433 match e { 422 434 JetstreamEventError::ReceiverClosedError => { ··· 428 440 JetstreamEventError::CompressionDictionaryError(_) => { 429 441 #[cfg(feature="metrics")] 430 442 counter!("jetstream_disconnects", "reason" => "zstd", "fatal" => "no").increment(1); 443 + } 444 + JetstreamEventError::NoMessagesReceived => { 445 + #[cfg(feature="metrics")] 446 + counter!("jetstream_disconnects", "reason" => "ttl", "fatal" => "no").increment(1); 431 447 } 432 448 JetstreamEventError::PingPongError(_) => { 433 449 #[cfg(feature="metrics")] ··· 481 497 ws: WebSocketStream<MaybeTlsStream<TcpStream>>, 482 498 send_channel: JetstreamSender, 483 499 last_cursor: &mut Option<Cursor>, 500 + liveliness_ttl: Duration, 484 501 ) -> Result<(), JetstreamEventError> { 485 502 // TODO: Use the write half to allow the user to change configuration settings on the fly. 486 503 let (mut socket_write, mut socket_read) = ws.split(); 487 504 488 505 let mut closing_connection = false; 489 506 loop { 490 - match socket_read.next().await { 507 + let next = match timeout(liveliness_ttl, socket_read.next()).await { 508 + Ok(n) => n, 509 + Err(_) => { 510 + log::warn!("jetstream no events for {liveliness_ttl:?}, closing"); 511 + _ = socket_write.close().await; 512 + return Err(JetstreamEventError::NoMessagesReceived); 513 + } 514 + }; 515 + match next { 491 516 Some(Ok(message)) => match message { 492 517 Message::Text(json) => { 493 518 #[cfg(feature = "metrics")]
+29
spacedust/Cargo.toml
··· 1 + [package] 2 + name = "spacedust" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [dependencies] 7 + async-trait = "0.1.88" 8 + clap = { version = "4.5.40", features = ["derive"] } 9 + ctrlc = "3.4.7" 10 + dropshot = "0.16.2" 11 + env_logger = "0.11.8" 12 + futures = "0.3.31" 13 + http = "1.3.1" 14 + jetstream = { path = "../jetstream", features = ["metrics"] } 15 + links = { path = "../links" } 16 + log = "0.4.27" 17 + metrics = "0.24.2" 18 + metrics-exporter-prometheus = { version = "0.17.1", features = ["http-listener"] } 19 + rand = "0.9.1" 20 + schemars = "0.8.22" 21 + semver = "1.0.26" 22 + serde = { version = "1.0.219", features = ["derive"] } 23 + serde_json = "1.0.140" 24 + serde_qs = "1.0.0-rc.3" 25 + thiserror = "2.0.12" 26 + tinyjson = "2.5.1" 27 + tokio = { version = "1.45.1", features = ["full"] } 28 + tokio-tungstenite = "0.27.0" 29 + tokio-util = "0.7.15"
+104
spacedust/src/consumer.rs
··· 1 + use tokio_util::sync::CancellationToken; 2 + use crate::LinkEvent; 3 + use crate::error::ConsumerError; 4 + use crate::removable_delay_queue; 5 + use jetstream::{ 6 + DefaultJetstreamEndpoints, JetstreamCompression, JetstreamConfig, JetstreamConnector, 7 + events::{CommitOp, Cursor, EventKind}, 8 + }; 9 + use links::collect_links; 10 + use tokio::sync::broadcast; 11 + 12 + const MAX_LINKS_PER_EVENT: usize = 100; 13 + 14 + pub async fn consume( 15 + b: broadcast::Sender<LinkEvent>, 16 + d: removable_delay_queue::Input<(String, usize), LinkEvent>, 17 + jetstream_endpoint: String, 18 + cursor: Option<Cursor>, 19 + no_zstd: bool, 20 + shutdown: CancellationToken, 21 + ) -> Result<(), ConsumerError> { 22 + let endpoint = DefaultJetstreamEndpoints::endpoint_or_shortcut(&jetstream_endpoint); 23 + if endpoint == jetstream_endpoint { 24 + log::info!("connecting to jetstream at {endpoint}"); 25 + } else { 26 + log::info!("connecting to jetstream at {jetstream_endpoint} => {endpoint}"); 27 + } 28 + let config: JetstreamConfig = JetstreamConfig { 29 + endpoint, 30 + compression: if no_zstd { 31 + JetstreamCompression::None 32 + } else { 33 + JetstreamCompression::Zstd 34 + }, 35 + replay_on_reconnect: true, 36 + channel_size: 1024, // buffer up to ~1s of jetstream events 37 + ..Default::default() 38 + }; 39 + let mut receiver = JetstreamConnector::new(config)? 40 + .connect_cursor(cursor) 41 + .await?; 42 + 43 + log::info!("receiving jetstream messages.."); 44 + loop { 45 + if shutdown.is_cancelled() { 46 + log::info!("exiting consumer for shutdown"); 47 + return Ok(()); 48 + } 49 + let Some(event) = receiver.recv().await else { 50 + log::error!("could not receive jetstream event, bailing"); 51 + break; 52 + }; 53 + 54 + if event.kind != EventKind::Commit { 55 + continue; 56 + } 57 + let Some(commit) = event.commit else { 58 + log::warn!("jetstream commit event missing commit data, ignoring"); 59 + continue; 60 + }; 61 + 62 + let at_uri = format!("at://{}/{}/{}", &*event.did, &*commit.collection, &*commit.rkey); 63 + 64 + // TODO: keep a buffer and remove quick deletes to debounce notifs 65 + // for now we just drop all deletes eek 66 + if commit.operation == CommitOp::Delete { 67 + d.remove_range((at_uri.clone(), 0)..=(at_uri.clone(), MAX_LINKS_PER_EVENT)).await; 68 + continue; 69 + } 70 + let Some(record) = commit.record else { 71 + log::warn!("jetstream commit update/delete missing record, ignoring"); 72 + continue; 73 + }; 74 + 75 + let jv = match record.get().parse() { 76 + Ok(v) => v, 77 + Err(e) => { 78 + log::warn!("jetstream record failed to parse, ignoring: {e}"); 79 + continue; 80 + } 81 + }; 82 + 83 + // todo: indicate if the link limit was reached (-> links omitted) 84 + for (i, link) in collect_links(&jv).into_iter().enumerate() { 85 + if i >= MAX_LINKS_PER_EVENT { 86 + log::warn!("jetstream event has too many links, ignoring the rest"); 87 + break; 88 + } 89 + let link_ev = LinkEvent { 90 + collection: commit.collection.to_string(), 91 + path: link.path, 92 + origin: at_uri.clone(), 93 + rev: commit.rev.to_string(), 94 + target: link.target.into_string(), 95 + }; 96 + let _ = b.send(link_ev.clone()); // only errors if no subscribers are connected, which is just fine. 97 + d.enqueue((at_uri.clone(), i), link_ev) 98 + .await 99 + .map_err(|_| ConsumerError::DelayQueueOutputDropped)?; 100 + } 101 + } 102 + 103 + Err(ConsumerError::JetstreamEnded) 104 + }
+23
spacedust/src/delay.rs
··· 1 + use crate::removable_delay_queue; 2 + use crate::LinkEvent; 3 + use tokio_util::sync::CancellationToken; 4 + use tokio::sync::broadcast; 5 + use crate::error::DelayError; 6 + 7 + pub async fn to_broadcast( 8 + source: removable_delay_queue::Output<(String, usize), LinkEvent>, 9 + dest: broadcast::Sender<LinkEvent>, 10 + shutdown: CancellationToken, 11 + ) -> Result<(), DelayError> { 12 + loop { 13 + tokio::select! { 14 + ev = source.next() => match ev { 15 + Some(event) => { 16 + let _ = dest.send(event); // only errors of there are no listeners, but that's normal 17 + }, 18 + None => return Err(DelayError::DelayEnded), 19 + }, 20 + _ = shutdown.cancelled() => return Ok(()), 21 + } 22 + } 23 + }
+43
spacedust/src/error.rs
··· 1 + use thiserror::Error; 2 + 3 + #[derive(Debug, Error)] 4 + pub enum MainTaskError { 5 + #[error(transparent)] 6 + ConsumerTaskError(#[from] ConsumerError), 7 + #[error(transparent)] 8 + ServerTaskError(#[from] ServerError), 9 + #[error(transparent)] 10 + DelayTaskError(#[from] DelayError), 11 + } 12 + 13 + #[derive(Debug, Error)] 14 + pub enum ConsumerError { 15 + #[error(transparent)] 16 + JetstreamConnectionError(#[from] jetstream::error::ConnectionError), 17 + #[error(transparent)] 18 + JetstreamConfigValidationError(#[from] jetstream::error::ConfigValidationError), 19 + #[error("jetstream ended")] 20 + JetstreamEnded, 21 + #[error("delay queue output dropped")] 22 + DelayQueueOutputDropped, 23 + } 24 + 25 + #[derive(Debug, Error)] 26 + pub enum DelayError { 27 + #[error("delay ended")] 28 + DelayEnded, 29 + } 30 + 31 + #[derive(Debug, Error)] 32 + pub enum ServerError { 33 + #[error("failed to configure server logger: {0}")] 34 + ConfigLogError(std::io::Error), 35 + #[error("failed to render json for openapi: {0}")] 36 + OpenApiJsonFail(serde_json::Error), 37 + #[error(transparent)] 38 + FailedToBuildServer(#[from] dropshot::BuildError), 39 + #[error("server exited: {0}")] 40 + ServerExited(String), 41 + #[error("server closed badly: {0}")] 42 + BadClose(String), 43 + }
+51
spacedust/src/lib.rs
··· 1 + pub mod consumer; 2 + pub mod delay; 3 + pub mod error; 4 + pub mod server; 5 + pub mod subscriber; 6 + pub mod removable_delay_queue; 7 + 8 + use serde::Serialize; 9 + 10 + #[derive(Debug, Clone)] 11 + pub struct LinkEvent { 12 + collection: String, 13 + path: String, 14 + origin: String, 15 + target: String, 16 + rev: String, 17 + } 18 + 19 + #[derive(Debug, Serialize)] 20 + #[serde(rename_all="snake_case")] 21 + pub struct ClientEvent { 22 + kind: String, // "link" 23 + origin: String, // "live", "replay", "backfill" 24 + link: ClientLinkEvent, 25 + } 26 + 27 + #[derive(Debug, Serialize)] 28 + struct ClientLinkEvent { 29 + operation: String, // "create", "delete" (prob no update, though maybe for rev?) 30 + source: String, 31 + source_record: String, 32 + source_rev: String, 33 + subject: String, 34 + // TODO: include the record too? would save clients a level of hydration 35 + } 36 + 37 + impl From<LinkEvent> for ClientLinkEvent { 38 + fn from(link: LinkEvent) -> Self { 39 + let undotted = link.path.strip_prefix('.').unwrap_or_else(|| { 40 + eprintln!("link path did not have expected '.' prefix: {}", link.path); 41 + "" 42 + }); 43 + Self { 44 + operation: "create".to_string(), 45 + source: format!("{}:{undotted}", link.collection), 46 + source_record: link.origin, 47 + source_rev: link.rev, 48 + subject: link.target, 49 + } 50 + } 51 + }
+139
spacedust/src/main.rs
··· 1 + use spacedust::error::MainTaskError; 2 + use spacedust::consumer; 3 + use spacedust::server; 4 + use spacedust::delay; 5 + use spacedust::removable_delay_queue::removable_delay_queue; 6 + 7 + use clap::Parser; 8 + use metrics_exporter_prometheus::PrometheusBuilder; 9 + use tokio::sync::broadcast; 10 + use tokio_util::sync::CancellationToken; 11 + use std::time::Duration; 12 + 13 + /// Aggregate links in the at-mosphere 14 + #[derive(Parser, Debug, Clone)] 15 + #[command(version, about, long_about = None)] 16 + struct Args { 17 + /// Jetstream server to connect to (exclusive with --fixture). Provide either a wss:// URL, or a shorhand value: 18 + /// 'us-east-1', 'us-east-2', 'us-west-1', or 'us-west-2' 19 + #[arg(long)] 20 + jetstream: String, 21 + /// don't request zstd-compressed jetstream events 22 + /// 23 + /// reduces CPU at the expense of more ingress bandwidth 24 + #[arg(long, action)] 25 + jetstream_no_zstd: bool, 26 + } 27 + 28 + #[tokio::main] 29 + async fn main() -> Result<(), String> { 30 + env_logger::init(); 31 + 32 + // tokio broadcast keeps a single main output queue for all subscribers. 33 + // each subscriber clones off a copy of an individual value for each recv. 34 + // since there's no large per-client buffer, we can make this one kind of 35 + // big and accommodate more slow/bursty clients. 36 + // 37 + // in fact, we *could* even keep lagging clients alive, inserting lag- 38 + // indicating messages to their output.... but for now we'll drop them to 39 + // avoid accumulating zombies. 40 + // 41 + // events on the channel are individual links as they are discovered. a link 42 + // contains a source and a target. the target is an at-uri, so it's up to 43 + // ~1KB max; source is a collection + link path, which can be more but in 44 + // practice the whole link rarely approaches 1KB total. 45 + // 46 + // TODO: determine if a pathological case could blow this up (eg 1MB link 47 + // paths + slow subscriber -> 16GiB queue) 48 + let (b, _) = broadcast::channel(16_384); 49 + let consumer_sender = b.clone(); 50 + let (d, _) = broadcast::channel(16_384); 51 + let consumer_delayed_sender = d.clone(); 52 + 53 + let delay = Duration::from_secs(21); 54 + let (delay_queue_sender, delay_queue_receiver) = removable_delay_queue(delay); 55 + 56 + let shutdown = CancellationToken::new(); 57 + 58 + let ctrlc_shutdown = shutdown.clone(); 59 + ctrlc::set_handler(move || ctrlc_shutdown.cancel()).expect("failed to set ctrl-c handler"); 60 + 61 + let args = Args::parse(); 62 + 63 + if let Err(e) = install_metrics_server() { 64 + log::error!("failed to install metrics server: {e:?}"); 65 + }; 66 + 67 + let mut tasks: tokio::task::JoinSet<Result<(), MainTaskError>> = tokio::task::JoinSet::new(); 68 + 69 + let server_shutdown = shutdown.clone(); 70 + tasks.spawn(async move { 71 + server::serve(b, d, server_shutdown).await?; 72 + Ok(()) 73 + }); 74 + 75 + let consumer_shutdown = shutdown.clone(); 76 + tasks.spawn(async move { 77 + consumer::consume( 78 + consumer_sender, 79 + delay_queue_sender, 80 + args.jetstream, 81 + None, 82 + args.jetstream_no_zstd, 83 + consumer_shutdown 84 + ) 85 + .await?; 86 + Ok(()) 87 + }); 88 + 89 + let delay_shutdown = shutdown.clone(); 90 + tasks.spawn(async move { 91 + delay::to_broadcast(delay_queue_receiver, consumer_delayed_sender, delay_shutdown).await?; 92 + Ok(()) 93 + }); 94 + 95 + tokio::select! { 96 + _ = shutdown.cancelled() => log::warn!("shutdown requested"), 97 + Some(r) = tasks.join_next() => { 98 + log::warn!("a task exited, shutting down: {r:?}"); 99 + shutdown.cancel(); 100 + } 101 + } 102 + 103 + tokio::select! { 104 + _ = async { 105 + while let Some(completed) = tasks.join_next().await { 106 + log::info!("shutdown: task completed: {completed:?}"); 107 + } 108 + } => {}, 109 + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => { 110 + log::info!("shutdown: not all tasks completed on time. aborting..."); 111 + tasks.shutdown().await; 112 + }, 113 + } 114 + 115 + log::info!("bye!"); 116 + 117 + Ok(()) 118 + } 119 + 120 + fn install_metrics_server() -> Result<(), metrics_exporter_prometheus::BuildError> { 121 + log::info!("installing metrics server..."); 122 + let host = [0, 0, 0, 0]; 123 + let port = 8765; 124 + PrometheusBuilder::new() 125 + .set_quantiles(&[0.5, 0.9, 0.99, 1.0])? 126 + .set_bucket_duration(std::time::Duration::from_secs(300))? 127 + .set_bucket_count(std::num::NonZero::new(12).unwrap()) // count * duration = 60 mins. stuff doesn't happen that fast here. 128 + .set_enable_unit_suffix(false) // this seemed buggy for constellation (sometimes wouldn't engage) 129 + .with_http_listener((host, port)) 130 + .install()?; 131 + log::info!( 132 + "metrics server installed! listening on http://{}.{}.{}.{}:{port}", 133 + host[0], 134 + host[1], 135 + host[2], 136 + host[3] 137 + ); 138 + Ok(()) 139 + }
+121
spacedust/src/removable_delay_queue.rs
··· 1 + use std::ops::RangeBounds; 2 + use std::collections::{BTreeMap, VecDeque}; 3 + use std::time::{Duration, Instant}; 4 + use tokio::sync::Mutex; 5 + use std::sync::Arc; 6 + use thiserror::Error; 7 + 8 + #[derive(Debug, Error)] 9 + pub enum EnqueueError<T> { 10 + #[error("queue ouput dropped")] 11 + OutputDropped(T), 12 + } 13 + 14 + pub trait Key: Eq + Ord + Clone {} 15 + impl<T: Eq + Ord + Clone> Key for T {} 16 + 17 + #[derive(Debug)] 18 + struct Queue<K: Key, T> { 19 + queue: VecDeque<(Instant, K)>, 20 + items: BTreeMap<K, T> 21 + } 22 + 23 + pub struct Input<K: Key, T> { 24 + q: Arc<Mutex<Queue<K, T>>>, 25 + } 26 + 27 + impl<K: Key, T> Input<K, T> { 28 + /// if a key is already present, its previous item will be overwritten and 29 + /// its delay time will be reset for the new item. 30 + /// 31 + /// errors if the remover has been dropped 32 + pub async fn enqueue(&self, key: K, item: T) -> Result<(), EnqueueError<T>> { 33 + if Arc::strong_count(&self.q) == 1 { 34 + return Err(EnqueueError::OutputDropped(item)); 35 + } 36 + // TODO: try to push out an old element first 37 + // for now we just hope there's a listener 38 + let now = Instant::now(); 39 + let mut q = self.q.lock().await; 40 + q.queue.push_back((now, key.clone())); 41 + q.items.insert(key, item); 42 + Ok(()) 43 + } 44 + /// remove an item from the queue, by key 45 + /// 46 + /// the item itself is removed, but the key will remain in the queue -- it 47 + /// will simply be skipped over when a new output item is requested. this 48 + /// keeps the removal cheap (=btreemap remove), for a bit of space overhead 49 + pub async fn remove_range(&self, range: impl RangeBounds<K>) { 50 + let n = { 51 + let mut q = self.q.lock().await; 52 + let keys = q.items.range(range).map(|(k, _)| k).cloned().collect::<Vec<_>>(); 53 + for k in &keys { 54 + q.items.remove(k); 55 + } 56 + keys.len() 57 + }; 58 + if n == 0 { 59 + metrics::counter!("delay_queue_remove_not_found").increment(1); 60 + } else { 61 + metrics::counter!("delay_queue_remove_total_records").increment(1); 62 + metrics::counter!("delay_queue_remove_total_links").increment(n as u64); 63 + } 64 + } 65 + } 66 + 67 + pub struct Output<K: Key, T> { 68 + delay: Duration, 69 + q: Arc<Mutex<Queue<K, T>>>, 70 + } 71 + 72 + impl<K: Key, T> Output<K, T> { 73 + pub async fn next(&self) -> Option<T> { 74 + let get = || async { 75 + let mut q = self.q.lock().await; 76 + metrics::gauge!("delay_queue_queue_len").set(q.queue.len() as f64); 77 + metrics::gauge!("delay_queue_queue_capacity").set(q.queue.capacity() as f64); 78 + while let Some((t, k)) = q.queue.pop_front() { 79 + // skip over queued keys that were removed from items 80 + if let Some(item) = q.items.remove(&k) { 81 + return Some((t, item)); 82 + } 83 + } 84 + None 85 + }; 86 + loop { 87 + if let Some((t, item)) = get().await { 88 + let now = Instant::now(); 89 + let expected_release = t + self.delay; 90 + if expected_release.saturating_duration_since(now) > Duration::from_millis(1) { 91 + tokio::time::sleep_until(expected_release.into()).await; 92 + metrics::counter!("delay_queue_emit_total", "early" => "yes").increment(1); 93 + metrics::histogram!("delay_queue_emit_overshoot").record(0); 94 + } else { 95 + let overshoot = now.saturating_duration_since(expected_release); 96 + metrics::counter!("delay_queue_emit_total", "early" => "no").increment(1); 97 + metrics::histogram!("delay_queue_emit_overshoot").record(overshoot.as_secs_f64()); 98 + } 99 + return Some(item) 100 + } else if Arc::strong_count(&self.q) == 1 { 101 + return None; 102 + } 103 + // the queue is *empty*, so we need to wait at least as long as the current delay 104 + tokio::time::sleep(self.delay).await; 105 + metrics::counter!("delay_queue_entirely_empty_total").increment(1); 106 + }; 107 + } 108 + } 109 + 110 + pub fn removable_delay_queue<K: Key, T>( 111 + delay: Duration, 112 + ) -> (Input<K, T>, Output<K, T>) { 113 + let q: Arc<Mutex<Queue<K, T>>> = Arc::new(Mutex::new(Queue { 114 + queue: VecDeque::new(), 115 + items: BTreeMap::new(), 116 + })); 117 + 118 + let input = Input::<K, T> { q: q.clone() }; 119 + let output = Output::<K, T> { q, delay }; 120 + (input, output) 121 + }
+324
spacedust/src/server.rs
··· 1 + use crate::error::ServerError; 2 + use crate::subscriber::Subscriber; 3 + use metrics::{histogram, counter}; 4 + use std::sync::Arc; 5 + use crate::LinkEvent; 6 + use http::{ 7 + header::{ORIGIN, USER_AGENT}, 8 + Response, StatusCode, 9 + }; 10 + use dropshot::{ 11 + Body, 12 + ApiDescription, ConfigDropshot, ConfigLogging, ConfigLoggingLevel, Query, RequestContext, 13 + ServerBuilder, WebsocketConnection, channel, endpoint, HttpResponse, 14 + ApiEndpointBodyContentType, ExtractorMetadata, HttpError, ServerContext, 15 + SharedExtractor, 16 + }; 17 + 18 + use schemars::JsonSchema; 19 + use serde::{Deserialize, Serialize}; 20 + use tokio::sync::broadcast; 21 + use tokio::time::Instant; 22 + use tokio_tungstenite::tungstenite::protocol::Role; 23 + use tokio_util::sync::CancellationToken; 24 + use async_trait::async_trait; 25 + use std::collections::HashSet; 26 + 27 + const INDEX_HTML: &str = include_str!("../static/index.html"); 28 + const FAVICON: &[u8] = include_bytes!("../static/favicon.ico"); 29 + 30 + pub async fn serve( 31 + b: broadcast::Sender<LinkEvent>, 32 + d: broadcast::Sender<LinkEvent>, 33 + shutdown: CancellationToken 34 + ) -> Result<(), ServerError> { 35 + let config_logging = ConfigLogging::StderrTerminal { 36 + level: ConfigLoggingLevel::Info, 37 + }; 38 + 39 + let log = config_logging 40 + .to_logger("example-basic") 41 + .map_err(ServerError::ConfigLogError)?; 42 + 43 + let mut api = ApiDescription::new(); 44 + api.register(index).unwrap(); 45 + api.register(favicon).unwrap(); 46 + api.register(openapi).unwrap(); 47 + api.register(subscribe).unwrap(); 48 + 49 + // TODO: put spec in a once cell / lazy lock thing? 50 + let spec = Arc::new( 51 + api.openapi( 52 + "Spacedust", 53 + env!("CARGO_PKG_VERSION") 54 + .parse() 55 + .inspect_err(|e| { 56 + eprintln!("failed to parse cargo package version for openapi: {e:?}") 57 + }) 58 + .unwrap_or(semver::Version::new(0, 0, 1)), 59 + ) 60 + .description("A configurable ATProto notifications firehose.") 61 + .contact_name("part of @microcosm.blue") 62 + .contact_url("https://microcosm.blue") 63 + .json() 64 + .map_err(ServerError::OpenApiJsonFail)?, 65 + ); 66 + 67 + let sub_shutdown = shutdown.clone(); 68 + let ctx = Context { spec, b, d, shutdown: sub_shutdown }; 69 + 70 + let server = ServerBuilder::new(api, ctx, log) 71 + .config(ConfigDropshot { 72 + bind_address: "0.0.0.0:9998".parse().unwrap(), 73 + ..Default::default() 74 + }) 75 + .start()?; 76 + 77 + tokio::select! { 78 + s = server.wait_for_shutdown() => { 79 + s.map_err(ServerError::ServerExited)?; 80 + log::info!("server shut down normally."); 81 + }, 82 + _ = shutdown.cancelled() => { 83 + log::info!("shutting down: closing server"); 84 + server.close().await.map_err(ServerError::BadClose)?; 85 + }, 86 + } 87 + Ok(()) 88 + } 89 + 90 + #[derive(Debug, Clone)] 91 + struct Context { 92 + pub spec: Arc<serde_json::Value>, 93 + pub b: broadcast::Sender<LinkEvent>, 94 + pub d: broadcast::Sender<LinkEvent>, 95 + pub shutdown: CancellationToken, 96 + } 97 + 98 + async fn instrument_handler<T, H, R>(ctx: &RequestContext<T>, handler: H) -> Result<R, HttpError> 99 + where 100 + R: HttpResponse, 101 + H: Future<Output = Result<R, HttpError>>, 102 + T: ServerContext, 103 + { 104 + let start = Instant::now(); 105 + let result = handler.await; 106 + let latency = start.elapsed(); 107 + let status_code = match &result { 108 + Ok(response) => response.status_code(), 109 + Err(e) => e.status_code.as_status(), 110 + } 111 + .as_str() // just the number (.to_string()'s Display does eg `200 OK`) 112 + .to_string(); 113 + let endpoint = ctx.endpoint.operation_id.clone(); 114 + let headers = ctx.request.headers(); 115 + let origin = headers 116 + .get(ORIGIN) 117 + .and_then(|v| v.to_str().ok()) 118 + .unwrap_or("") 119 + .to_string(); 120 + let ua = headers 121 + .get(USER_AGENT) 122 + .and_then(|v| v.to_str().ok()) 123 + .map(|ua| { 124 + if ua.starts_with("Mozilla/5.0 ") { 125 + "browser" 126 + } else { 127 + ua 128 + } 129 + }) 130 + .unwrap_or("") 131 + .to_string(); 132 + counter!("server_requests_total", 133 + "endpoint" => endpoint.clone(), 134 + "origin" => origin, 135 + "ua" => ua, 136 + "status_code" => status_code, 137 + ) 138 + .increment(1); 139 + histogram!("server_handler_latency", "endpoint" => endpoint).record(latency.as_micros() as f64); 140 + result 141 + } 142 + 143 + use dropshot::{HttpResponseHeaders, HttpResponseOk}; 144 + 145 + pub type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>; 146 + 147 + /// Helper for constructing Ok responses: return OkCors(T).into() 148 + /// (not happy with this yet) 149 + pub struct OkCors<T: Serialize + JsonSchema + Send + Sync>(pub T); 150 + 151 + impl<T> From<OkCors<T>> for OkCorsResponse<T> 152 + where 153 + T: Serialize + JsonSchema + Send + Sync, 154 + { 155 + fn from(ok: OkCors<T>) -> OkCorsResponse<T> { 156 + let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0)); 157 + res.headers_mut() 158 + .insert("access-control-allow-origin", "*".parse().unwrap()); 159 + Ok(res) 160 + } 161 + } 162 + 163 + // TODO: cors for HttpError 164 + 165 + 166 + /// Serve index page as html 167 + #[endpoint { 168 + method = GET, 169 + path = "/", 170 + /* 171 + * not useful to have this in openapi 172 + */ 173 + unpublished = true, 174 + }] 175 + async fn index(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> { 176 + instrument_handler(&ctx, async { 177 + Ok(Response::builder() 178 + .status(StatusCode::OK) 179 + .header(http::header::CONTENT_TYPE, "text/html") 180 + .body(INDEX_HTML.into())?) 181 + }) 182 + .await 183 + } 184 + 185 + /// Serve index page as html 186 + #[endpoint { 187 + method = GET, 188 + path = "/favicon.ico", 189 + /* 190 + * not useful to have this in openapi 191 + */ 192 + unpublished = true, 193 + }] 194 + async fn favicon(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> { 195 + instrument_handler(&ctx, async { 196 + Ok(Response::builder() 197 + .status(StatusCode::OK) 198 + .header(http::header::CONTENT_TYPE, "image/x-icon") 199 + .body(FAVICON.to_vec().into())?) 200 + }) 201 + .await 202 + } 203 + 204 + /// Meta: get the openapi spec for this api 205 + #[endpoint { 206 + method = GET, 207 + path = "/openapi", 208 + /* 209 + * not useful to have this in openapi 210 + */ 211 + unpublished = true, 212 + }] 213 + async fn openapi(ctx: RequestContext<Context>) -> OkCorsResponse<serde_json::Value> { 214 + instrument_handler(&ctx, async { 215 + let spec = (*ctx.context().spec).clone(); 216 + OkCors(spec).into() 217 + }) 218 + .await 219 + } 220 + 221 + /// The real type that gets deserialized 222 + #[derive(Debug, Deserialize, JsonSchema)] 223 + #[serde(rename_all = "camelCase")] 224 + pub struct MultiSubscribeQuery { 225 + #[serde(default)] 226 + pub wanted_subjects: HashSet<String>, 227 + #[serde(default)] 228 + pub wanted_subject_dids: HashSet<String>, 229 + #[serde(default)] 230 + pub wanted_sources: HashSet<String>, 231 + } 232 + /// The fake corresponding type for docs that dropshot won't freak out about a 233 + /// vec for 234 + #[derive(Deserialize, JsonSchema)] 235 + #[allow(dead_code)] 236 + #[serde(rename_all = "camelCase")] 237 + struct MultiSubscribeQueryForDocs { 238 + /// One or more at-uris to receive links about 239 + /// 240 + /// The at-uri must be url-encoded 241 + /// 242 + /// Pass this parameter multiple times to specify multiple collections, like 243 + /// `wantedSubjects=[...]&wantedSubjects=[...]` 244 + pub wanted_subjects: String, 245 + /// One or more DIDs to receive links about 246 + /// 247 + /// Pass this parameter multiple times to specify multiple collections 248 + pub wanted_subject_dids: String, 249 + /// One or more link sources to receive links about 250 + /// 251 + /// TODO: docs about link sources 252 + /// 253 + /// eg, a bluesky like's link source: `app.bsky.feed.like:subject.uri` 254 + /// 255 + /// Pass this parameter multiple times to specify multiple sources 256 + pub wanted_sources: String, 257 + } 258 + 259 + // The `SharedExtractor` implementation for Query<QueryType> describes how to 260 + // construct an instance of `Query<QueryType>` from an HTTP request: namely, by 261 + // parsing the query string to an instance of `QueryType`. 262 + #[async_trait] 263 + impl SharedExtractor for MultiSubscribeQuery { 264 + async fn from_request<Context: ServerContext>( 265 + ctx: &RequestContext<Context>, 266 + ) -> Result<MultiSubscribeQuery, HttpError> { 267 + let raw_query = ctx.request.uri().query().unwrap_or(""); 268 + let q = serde_qs::from_str(raw_query).map_err(|e| { 269 + HttpError::for_bad_request(None, format!("unable to parse query string: {}", e)) 270 + })?; 271 + Ok(q) 272 + } 273 + 274 + fn metadata(body_content_type: ApiEndpointBodyContentType) -> ExtractorMetadata { 275 + // HACK: query type switcheroo: passing MultiSubscribeQuery to 276 + // `metadata` would "helpfully" panic because dropshot believes we can 277 + // only have scalar types in a query. 278 + // 279 + // so instead we have a fake second type whose only job is to look the 280 + // same as MultiSubscribeQuery exept that it has `String` instead of 281 + // `Vec<String>`, which dropshot will accept, and generate ~close-enough 282 + // docs for. 283 + <Query<MultiSubscribeQueryForDocs> as SharedExtractor>::metadata(body_content_type) 284 + } 285 + } 286 + 287 + #[derive(Deserialize, JsonSchema)] 288 + #[serde(rename_all = "camelCase")] 289 + struct ScalarSubscribeQuery { 290 + #[serde(default)] 291 + pub instant: bool, 292 + } 293 + 294 + #[channel { 295 + protocol = WEBSOCKETS, 296 + path = "/subscribe", 297 + }] 298 + async fn subscribe( 299 + reqctx: RequestContext<Context>, 300 + query: MultiSubscribeQuery, 301 + scalar_query: Query<ScalarSubscribeQuery>, 302 + upgraded: WebsocketConnection, 303 + ) -> dropshot::WebsocketChannelResult { 304 + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( 305 + upgraded.into_inner(), 306 + Role::Server, 307 + None, 308 + ) 309 + .await; 310 + 311 + let Context { b, d, shutdown, .. } = reqctx.context(); 312 + let sub_token = shutdown.child_token(); 313 + 314 + let q = scalar_query.into_inner(); 315 + let subscription = if q.instant { b } else { d }.subscribe(); 316 + log::info!("starting subscriber with broadcast: instant={}", q.instant); 317 + 318 + Subscriber::new(query, sub_token) 319 + .start(ws, subscription) 320 + .await 321 + .map_err(|e| format!("boo: {e:?}"))?; 322 + 323 + Ok(()) 324 + }
+163
spacedust/src/subscriber.rs
··· 1 + use tokio::time::interval; 2 + use std::time::Duration; 3 + use futures::StreamExt; 4 + use crate::ClientEvent; 5 + use crate::LinkEvent; 6 + use crate::server::MultiSubscribeQuery; 7 + use futures::SinkExt; 8 + use std::error::Error; 9 + use tokio::sync::broadcast::{self, error::RecvError}; 10 + use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; 11 + use tokio_util::sync::CancellationToken; 12 + use dropshot::WebsocketConnectionRaw; 13 + 14 + const PING_PERIOD: Duration = Duration::from_secs(30); 15 + 16 + pub struct Subscriber { 17 + query: MultiSubscribeQuery, 18 + shutdown: CancellationToken, 19 + } 20 + 21 + impl Subscriber { 22 + pub fn new( 23 + query: MultiSubscribeQuery, 24 + shutdown: CancellationToken, 25 + ) -> Self { 26 + Self { query, shutdown } 27 + } 28 + 29 + pub async fn start( 30 + self, 31 + ws: WebSocketStream<WebsocketConnectionRaw>, 32 + mut receiver: broadcast::Receiver<LinkEvent> 33 + ) -> Result<(), Box<dyn Error>> { 34 + let mut ping_state = None; 35 + let (mut ws_sender, mut ws_receiver) = ws.split(); 36 + let mut ping_interval = interval(PING_PERIOD); 37 + let _guard = self.shutdown.clone().drop_guard(); 38 + 39 + // TODO: do we need to timeout ws sends?? 40 + 41 + metrics::counter!("subscribers_connected_total").increment(1); 42 + metrics::gauge!("subscribers_connected").increment(1); 43 + 44 + loop { 45 + tokio::select! { 46 + l = receiver.recv() => match l { 47 + Ok(link) => if let Some(message) = self.filter(link) { 48 + if let Err(e) = ws_sender.send(message).await { 49 + log::warn!("failed to send link, dropping subscriber: {e:?}"); 50 + break; 51 + } 52 + }, 53 + Err(RecvError::Closed) => self.shutdown.cancel(), 54 + Err(RecvError::Lagged(n)) => { 55 + log::warn!("dropping lagging subscriber (missed {n} messages already)"); 56 + self.shutdown.cancel(); 57 + } 58 + }, 59 + cm = ws_receiver.next() => match cm { 60 + Some(Ok(Message::Ping(state))) => { 61 + if let Err(e) = ws_sender.send(Message::Pong(state)).await { 62 + log::error!("failed to reply pong to subscriber: {e:?}"); 63 + break; 64 + } 65 + } 66 + Some(Ok(Message::Pong(state))) => { 67 + if let Some(expected_state) = ping_state { 68 + if *state == expected_state { 69 + ping_state = None; // good 70 + } else { 71 + log::error!("subscriber returned a pong with the wrong state, dropping"); 72 + self.shutdown.cancel(); 73 + } 74 + } else { 75 + log::error!("subscriber sent a pong when none was expected"); 76 + self.shutdown.cancel(); 77 + } 78 + } 79 + Some(Ok(m)) => log::trace!("subscriber sent an unexpected message: {m:?}"), 80 + Some(Err(e)) => { 81 + log::error!("failed to receive subscriber message: {e:?}"); 82 + break; 83 + } 84 + None => { 85 + log::trace!("end of subscriber messages. bye!"); 86 + break; 87 + } 88 + }, 89 + _ = ping_interval.tick() => { 90 + if ping_state.is_some() { 91 + log::warn!("did not recieve pong within {PING_PERIOD:?}, dropping subscriber"); 92 + self.shutdown.cancel(); 93 + } else { 94 + let new_state: [u8; 8] = rand::random(); 95 + let ping = new_state.to_vec().into(); 96 + ping_state = Some(new_state); 97 + if let Err(e) = ws_sender.send(Message::Ping(ping)).await { 98 + log::error!("failed to send ping to subscriber, dropping: {e:?}"); 99 + self.shutdown.cancel(); 100 + } 101 + } 102 + } 103 + _ = self.shutdown.cancelled() => { 104 + log::info!("subscriber shutdown requested, bye!"); 105 + if let Err(e) = ws_sender.close().await { 106 + log::warn!("failed to close subscriber: {e:?}"); 107 + } 108 + break; 109 + }, 110 + } 111 + } 112 + log::trace!("end of subscriber. bye!"); 113 + metrics::gauge!("subscribers_connected").decrement(1); 114 + Ok(()) 115 + } 116 + 117 + fn filter( 118 + &self, 119 + link: LinkEvent, 120 + // mut sender: impl Sink<Message> + Unpin 121 + ) -> Option<Message> { 122 + let query = &self.query; 123 + 124 + // subject + subject DIDs are logical OR 125 + let target_did = if link.target.starts_with("did:") { 126 + link.target.clone() 127 + } else { 128 + let rest = link.target.strip_prefix("at://")?; 129 + if let Some((did, _)) = rest.split_once("/") { 130 + did 131 + } else { 132 + rest 133 + }.to_string() 134 + }; 135 + if !(query.wanted_subjects.contains(&link.target) || query.wanted_subject_dids.contains(&target_did) || query.wanted_subjects.is_empty() && query.wanted_subject_dids.is_empty()) { 136 + // wowwww ^^ fix that 137 + return None 138 + } 139 + 140 + // subjects together with sources are logical AND 141 + 142 + if !query.wanted_sources.is_empty() { 143 + let undotted = link.path.strip_prefix('.').unwrap_or_else(|| { 144 + eprintln!("link path did not have expected '.' prefix: {}", link.path); 145 + "" 146 + }); 147 + let source = format!("{}:{undotted}", link.collection); 148 + if !query.wanted_sources.contains(&source) { 149 + return None 150 + } 151 + } 152 + 153 + let ev = ClientEvent { 154 + kind: "link".to_string(), 155 + origin: "live".to_string(), 156 + link: link.into(), 157 + }; 158 + 159 + let json = serde_json::to_string(&ev).unwrap(); 160 + 161 + Some(Message::Text(json.into())) 162 + } 163 + }
spacedust/static/favicon.ico

This is a binary file and will not be displayed.

+54
spacedust/static/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Spacedust documentation</title> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="description" content="API Documentation for Spacedust, a configurable ATProto notifications firehose" /> 8 + <style> 9 + .custom-header { 10 + height: 42px; 11 + background-color: #221828; 12 + box-shadow: inset 0 -1px 0 var(--scalar-border-color); 13 + color: var(--scalar-color-1); 14 + font-size: var(--scalar-font-size-3); 15 + font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; 16 + padding: 0 18px; 17 + justify-content: space-between; 18 + } 19 + .custom-header, 20 + .custom-header nav { 21 + display: flex; 22 + align-items: center; 23 + gap: 18px; 24 + } 25 + .custom-header a:hover { 26 + color: var(--scalar-color-2); 27 + } 28 + </style> 29 + </head> 30 + <body> 31 + <header class="custom-header scalar-app"> 32 + <p> 33 + TODO: pdsls jetstream link 34 + <a href="https://ufos.microcosm.blue">Launch 🛸 UFOs app</a>: Explore lexicons 35 + </p> 36 + <nav> 37 + <b>a <a href="https://microcosm.blue">microcosm</a> project</b> 38 + <a href="https://bsky.app/profile/microcosm.blue">@microcosm.blue</a> 39 + <a href="https://github.com/at-microcosm">github</a> 40 + </nav> 41 + </header> 42 + 43 + <script id="api-reference" type="application/json" data-url="/openapi""></script> 44 + 45 + <script> 46 + var configuration = { 47 + theme: 'purple', 48 + } 49 + document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration) 50 + </script> 51 + 52 + <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script> 53 + </body> 54 + </html>
+1 -1
ufos/src/index_html.rs
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>UFOs API Documentation</title> 5 + <title>UFOs API documentation</title> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 7 <meta name="description" content="API Documentation for UFOs: Samples and stats for all atproto lexicons." /> 8 8 <style>
+1 -1
ufos/src/storage_fjall.rs
··· 40 40 /// 41 41 /// new data format, roughly: 42 42 /// 43 - /// Partion: 'global' 43 + /// Partition: 'global' 44 44 /// 45 45 /// - Global sequence counter (is the jetstream cursor -- monotonic with many gaps) 46 46 /// - key: "js_cursor" (literal)