binary: axum signaling + DashMap session store + browser test client

Four routes on axum 0.7 per spec §4.1: POST /v1/sessions (mint),
POST /v1/sessions/:id/offer (str0m-native SDP accept), DELETE
/v1/sessions/:id (close), GET / (static HTML client). Session store is
a DashMap<ChannelId, Arc<Mutex<RtcSession>>> (spec §4.5); one tokio
task drives all session poll loops — per-session tasks would pre-pave
the wrong pattern for step 4's dedicated thread. Graceful shutdown
drops the DashMap on Ctrl-C / SIGTERM. Integration test exercises the
REST surface; manual browser e2e per README §6.5.
This commit is contained in:
adlee-was-taken
2026-06-28 12:48:02 -04:00
parent 34c590b48a
commit 1f0f64c1c6
10 changed files with 1052 additions and 1 deletions

574
Cargo.lock generated
View File

@@ -23,6 +23,15 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.7.7"
@@ -68,6 +77,23 @@ dependencies = [
"syn",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "audiopus_sys"
version = "0.2.2"
@@ -108,6 +134,73 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "base16ct"
version = "0.2.0"
@@ -120,6 +213,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -221,6 +320,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -240,6 +345,20 @@ dependencies = [
"cipher",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -335,6 +454,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -353,12 +482,30 @@ version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -416,6 +563,92 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -477,24 +710,65 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -514,6 +788,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -572,6 +855,29 @@ dependencies = [
"audiopus_sys",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -581,6 +887,12 @@ dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -690,6 +1002,32 @@ dependencies = [
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -720,6 +1058,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rutster"
version = "0.0.0"
dependencies = [
"axum",
"dashmap",
"rutster-call-model",
"rutster-media",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "rutster-call-model"
version = "0.0.0"
@@ -750,6 +1106,18 @@ version = "0.0.0"
name = "rutster-tap"
version = "0.0.0"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctp-proto"
version = "0.10.0"
@@ -807,12 +1175,67 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@@ -825,6 +1248,22 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -899,6 +1338,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -950,6 +1395,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.41"
@@ -981,12 +1435,69 @@ dependencies = [
"time-core",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -1010,6 +1521,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -1041,12 +1582,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -1101,6 +1654,21 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -1170,3 +1738,9 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -5,6 +5,7 @@
[workspace]
resolver = "2"
members = [
"crates/rutster",
"crates/rutster-call-model",
"crates/rutster-media",
"crates/rutster-signaling-sip",

View File

@@ -73,7 +73,7 @@ use str0m::media::Mid;
/// contract on `handle_input` + `poll_output`). `RwLock`'s read-mode
/// would be useless because str0m takes `&mut Rtc`. `Mutex` it is.
pub struct RtcSession {
pub(crate) channel: Channel,
pub channel: Channel,
pub(crate) rtc: Rtc,
pub(crate) decoder: OpusDecoder,
pub(crate) encoder: OpusEncoder,

28
crates/rutster/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
# crates/rutster/Cargo.toml
[package]
name = "rutster"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Rutster binary: axum signaling + media driver + static browser test client (slice 1)."
[dependencies]
rutster-call-model = { path = "../rutster-call-model" }
rutster-media = { path = "../rutster-media" }
axum = { workspace = true }
tokio = { workspace = true }
dashmap = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tower = { workspace = true }
[[bin]]
name = "rutster"
path = "src/main.rs"

26
crates/rutster/src/lib.rs Normal file
View File

@@ -0,0 +1,26 @@
//! # rutster (lib) — slice-1 binary library target
//!
//! Thin library surface re-exporting the modules the `main.rs` binary
//! and the `tests/api_integration.rs` integration test both need.
//!
//! # Why a `lib.rs` *and* a `main.rs`?
//!
//! Pure binary crates (`src/main.rs` only) do not surface items via the
//! crate's external path (`rutster::routes::router`) — Cargo's
//! integration tests (`tests/*.rs`) link against the *library* target,
//! not the binary target. With `pub mod` declared only in `main.rs`,
//! `use rutster::routes::router` in the integration test fails to
//! resolve (`E0433`). The library/binary split (the standard Cargo
//! pattern: a crate compiles both `lib.rs` → `librutster` and `main.rs`
//! → `rutster`, with the binary linking the library) makes the same
//! `pub mod` declarations visible to both consumers. `main.rs` then
//! imports them via `use rutster::...` paths exactly as the brief's
//! Step 6 sketches.
//!
//! ## Architecture refs
//!
//! - [slice-1 spec §4](../../docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md)
//! - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — fused vertical.
pub mod routes;
pub mod session_map;

View File

@@ -0,0 +1,66 @@
//! # rutster — slice-1 binary
//!
//! axum signaling server + the media-core poll driver + a static HTML
//! test client (spec §4). Binds `0.0.0.0:8080` plaintext — no TLS in
//! slice 1 (out of scope per §1.2). DTLS-SRTP is mandatory on the media
//! surface (str0m handles natively); TLS on the HTTP surface lands with
//! the deployment posture in step 5.
//!
//! ## Architecture refs
//!
//! - [slice-1 spec §4](../../docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md)
//! - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — fused vertical.
use std::net::SocketAddr;
use rutster::routes::router;
use rutster::session_map::AppState;
use tracing::info;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rutster=info".into()),
)
.init();
let state = AppState::new();
state.clone().spawn_poll_task().await;
let addr: SocketAddr = "0.0.0.0:8080".parse().expect("valid addr");
info!(%addr, "listening");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, router(state))
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
/// Ctrl-C / SIGTERM handler (spec §4.5). Dropping the AppState drops the
/// DashMap, which drops every RtcSession, which str0m sees as a closed
/// peer — browsers get a dead peer connection. Acceptable for the dev
/// loop; no in-flight call preservation story in slice 1.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("installed ctrl-c handler");
};
#[cfg(unix)]
let sigterm = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("installed SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let sigterm = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => info!("received Ctrl-C, shutting down"),
_ = sigterm => info!("received SIGTERM, shutting down"),
}
}

View File

@@ -0,0 +1,107 @@
//! # HTTP routes (spec §4.1, §4.3)
//!
//! Four routes on axum 0.7:
//! - `POST /v1/sessions` → `{ "session_id": "<uuid>" }`.
//! - `POST /v1/sessions/:id/offer` (`Content-Type: application/sdp` req
//! + response) → core returns the SDP answer.
//! - `DELETE /v1/sessions/:id` → tear down.
//! - `GET /` → serve the static HTML test client.
//!
//! No authn/authz, no TLS, no multi-tenancy — all deferred per spec §1.2.
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Serialize;
use uuid::Uuid;
use crate::session_map::AppState;
#[derive(Serialize)]
struct SessionCreated {
session_id: String,
}
/// POST /v1/sessions — mint a fresh RtcSession (spec §4.1).
pub async fn create_session(State(state): State<AppState>) -> Response {
match state.create_session() {
Ok(id) => {
let body = Json(SessionCreated {
session_id: id.0.to_string(),
});
(StatusCode::OK, body).into_response()
}
Err(e) => {
tracing::error!(error = ?e, "session create failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// POST /v1/sessions/:id/offer — accept browser SDP offer, return answer
/// (spec §4.1). Non-trickle: the offer body carries all browser ICE
/// candidates; the answer carries the core's candidates (filled natively
/// by str0m 0.21's sdp_api().accept_offer).
pub async fn post_offer(
State(state): State<AppState>,
Path(id_str): Path<String>,
body: String,
) -> Response {
let Ok(id_uuid) = Uuid::parse_str(&id_str) else {
return (StatusCode::NOT_FOUND, "bad session id").into_response();
};
let id = rutster_call_model::ChannelId(id_uuid);
let Some(session_arc) = state.get(id) else {
return (StatusCode::NOT_FOUND, "no such session").into_response();
};
let mut s = session_arc.lock().await;
match s.accept_offer(&body) {
Ok(answer_sdp) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/sdp")],
answer_sdp,
)
.into_response(),
Err(e) => {
tracing::error!(error = ?e, "SDP accept failed");
StatusCode::BAD_REQUEST.into_response()
}
}
}
/// DELETE /v1/sessions/:id — tear down (spec §4.1, §4.5).
pub async fn delete_session(State(state): State<AppState>, Path(id_str): Path<String>) -> Response {
let Ok(id_uuid) = Uuid::parse_str(&id_str) else {
return StatusCode::NOT_FOUND.into_response();
};
let id = rutster_call_model::ChannelId(id_uuid);
state.close(id).await;
StatusCode::NO_CONTENT.into_response()
}
/// GET / — serve the static browser test client (spec §4.4).
pub async fn index() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
include_str!("../static/index.html"),
)
.into_response()
}
/// Build the axum router.
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
// `POST /v1/sessions` creates; `DELETE /v1/sessions/:id` destroys
// (note the `:id` — deleting the collection root has no meaning and
// would extract a missing `:id` path parameter, so the two routes
// live at different paths, not chained via `.delete(...)` on the
// collection route as axum's method chaining would suggest).
.route("/v1/sessions", post(create_session))
.route("/v1/sessions/:id", axum::routing::delete(delete_session))
.route("/v1/sessions/:id/offer", post(post_offer))
.with_state(state)
}

View File

@@ -0,0 +1,121 @@
//! # Session store + poll-driver (spec §4.5)
//!
//! `DashMap<ChannelId, RtcSession>` holds active sessions; the `ChannelId`
//! (UUID newtype from `rutster-call-model`) IS the session id surfaced in
//! the REST API. A single tokio task drives all sessions' poll loops (a
//! per-session task would clutter the runtime and pre-pave the wrong
//! pattern for the step-4 dedicated thread — spec §4.5).
//!
//! # Concurrency note
//!
//! `DashMap` shards its inner `HashMap` so concurrent gets/puts across
//! different `ChannelId`s don't contend. We iterate per-shard inside the
//! poll task to drive each session; entries marked `Closed` are removed.
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use rutster_call_model::{ChannelId, ChannelState};
use rutster_media::{RtcSession, RtcSessionError};
use tokio::sync::Mutex;
use tracing::{debug, info};
/// The application state shared across axum handlers + the poll task.
///
/// # Why `Arc` (and not bare)
///
/// axum clones the state into every handler. `Arc` is the standard way
/// to share `DashMap` + `Mutex` owned state across these clones cheaply
/// (a single heap allocation, refcount-bumped per clone). Without `Arc`,
/// every handler would move its own copy — and `DashMap` is not `Copy`.
///
/// # Why a separate `poll_running` `Mutex`
///
/// The poll loop is one task; we don't want two. The Mutex guards a
/// once-only spawn: `spawn_poll_task` checks-and-sets it under the mutex.
/// `Mutex` (not `RwLock`) because the only operation is "take it once."
#[derive(Clone)]
pub struct AppState {
pub sessions: Arc<DashMap<ChannelId, Arc<Mutex<RtcSession>>>>,
pub poll_running: Arc<Mutex<bool>>,
}
impl AppState {
pub fn new() -> Self {
Self {
sessions: Arc::new(DashMap::new()),
poll_running: Arc::new(Mutex::new(false)),
}
}
/// Mint a fresh `RtcSession`, store it under its `ChannelId`, return the id.
pub fn create_session(&self) -> Result<ChannelId, RtcSessionError> {
let session = RtcSession::new()?;
let id = session.channel_id();
self.sessions.insert(id, Arc::new(Mutex::new(session)));
Ok(id)
}
/// Look up a session by id (returns the clone of the Arc-wrapped Mutex).
pub fn get(&self, id: ChannelId) -> Option<Arc<Mutex<RtcSession>>> {
self.sessions.get(&id).map(|r| r.clone())
}
/// Transition to Closing then drop the entry (spec §4.1 — DELETE).
pub async fn close(&self, id: ChannelId) {
if let Some((_id, session_arc)) = self.sessions.remove(&id) {
let mut s = session_arc.lock().await;
s.channel.state = ChannelState::Closing;
s.channel.state = ChannelState::Closed;
info!(channel_id = %id, "session closed via DELETE");
}
}
/// Spawn the single poll task for all sessions (idempotent).
pub async fn spawn_poll_task(self) {
let mut running = self.poll_running.lock().await;
if *running {
return;
}
*running = true;
drop(running);
let state = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(10));
interval.tick().await;
loop {
interval.tick().await;
let now = Instant::now();
drive_all_sessions(&state, now).await;
}
});
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
/// One iteration of "drive every active session." Removes closed entries.
async fn drive_all_sessions(state: &AppState, now: Instant) {
// Collect ids first to avoid holding the DashMap shard during the
// async poll (which would block other handlers mutating the same shard).
let ids: Vec<ChannelId> = state.sessions.iter().map(|r| *r.key()).collect();
for id in ids {
let session_arc = match state.sessions.get(&id) {
Some(r) => r.clone(),
None => continue,
};
let mut s = session_arc.lock().await;
let _ = s.run_poll_once(now); // hot-path match-and-continue inside
if s.is_closed() {
drop(s);
state.sessions.remove(&id);
debug!(channel_id = %id, "session evicted after close");
}
}
}

View File

@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Rutster slice-1 — WebRTC loopback</title>
<style>
body { font: 14px/1.4 system-ui, sans-serif; max-width: 60ch; margin: 2rem auto; }
pre { background: #f4f4f4; padding: 1rem; overflow: auto; }
button { font: inherit; padding: 0.4rem 1rem; }
</style>
</head>
<body>
<h1>Rutster slice-1 — WebRTC loopback</h1>
<p>Speak; you should hear yourself back within ~200 ms.</p>
<button id="start">Start call</button>
<button id="mute" disabled>Mute mic</button>
<button id="hangup" disabled>Hang up</button>
<pre id="log"></pre>
<audio id="audio" autoplay></audio>
<script>
const log = (s) => { document.getElementById('log').textContent += s + '\n'; };
const $ = (id) => document.getElementById(id);
let pc, sessionId, localStream;
$('start').onclick = async () => {
$('start').disabled = true;
$('mute').disabled = false;
$('hangup').disabled = false;
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// No STUN (host candidates only — spec §4.4, zero-dependency dev loop).
pc = new RTCPeerConnection({ iceServers: [] });
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
pc.oniceconnectionstatechange = () => log('ICE: ' + pc.iceConnectionState);
pc.onconnectionstatechange = () => log('conn: ' + pc.connectionState);
pc.ontrack = (e) => { $('audio').srcObject = e.streams[0]; };
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Non-trickle: wait for ICE gathering to complete so all candidates
// travel in the SDP offer POST.
await new Promise(r => {
if (pc.iceGatheringState === 'complete') r();
else { pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') r(); }; };
});
const resp = await fetch('/v1/sessions', { method: 'POST' });
const { session_id } = await resp.json();
sessionId = session_id;
log('session: ' + session_id);
const ans = await fetch(`/v1/sessions/${session_id}/offer`, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp,
});
const answerSdp = await ans.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
};
$('mute').onclick = () => {
const track = localStream.getAudioTracks()[0];
track.enabled = !track.enabled;
$('mute').textContent = track.enabled ? 'Mute mic' : 'Unmute mic';
};
$('hangup').onclick = async () => {
await fetch(`/v1/sessions/${sessionId}`, { method: 'DELETE' });
pc.close();
$('hangup').disabled = true;
$('mute').disabled = true;
$('start').disabled = false;
log('hung up');
};
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
//! Integration test for the slice-1 REST surface (spec §4.1, §6.4).
//!
//! Spins up the axum app on an ephemeral port, then exercises the API
//! contract: POST /v1/sessions → JSON { session_id }; GET / serves a
//! text/html page. We do NOT exercise the WebRTC handshake here (that
//! needs a real peer); the manual e2e plan in README.md covers it.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use rutster::session_map::AppState;
use tower::ServiceExt; // enables `oneshot` on the Router for sync tests
#[tokio::test]
async fn post_v1_sessions_returns_a_session_id() {
let app = rutster::routes::router(AppState::new());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v["session_id"].is_string(), "response has session_id");
assert_eq!(v["session_id"].as_str().unwrap().len(), 36); // UUID v4
}
#[tokio::test]
async fn get_root_serves_html() {
let app = rutster::routes::router(AppState::new());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.map(|v| v.to_str().unwrap()),
Some("text/html; charset=utf-8")
);
}