Skip to content

Commit e7b381a

Browse files
allan sargeantclaude
andcommitted
Add websocket push for the crosspoint UI, update docs to match
- GET /ws pushes crosspoint state on connect and on every change (~200ms detection), so the grid updates live with no client polling once connected. REST GET /api/state kept for first paint and as a fallback. Verified live in a real browser: a route change made via curl (i.e. from outside the page) appeared in the grid with zero interaction. - README/architecture/roadmap updated to reflect tonight's actual verified status: real end-to-end SRT relay integration tests, CI, route persistence, and websocket push are all done; the one remaining honest gap is testing against real third-party SRT hardware / a non-loopback network path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b029e2d commit e7b381a

7 files changed

Lines changed: 215 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
> **AI-assisted project.** This codebase was created with [Claude](https://claude.com/claude-code)
44
> (Anthropic), directed and reviewed by a human author. The relay/crosspoint
5-
> engine and web UI have been exercised locally (unit tests, real SRT
6-
> listener sockets, a live crosspoint switch via the web UI — see
7-
> [Status](#status)) but **not yet run against real-world SRT
8-
> encoders/decoders or over an actual network path**. Review before relying
9-
> on it for anything live.
5+
> engine and web UI have been exercised locally — including integration
6+
> tests that relay real SRT protocol traffic end-to-end and a live
7+
> crosspoint switch via the web UI, see [Status](#status) — but **not yet
8+
> run against real-world third-party SRT encoders/decoders or over an
9+
> actual (non-loopback) network path**. Review before relying on it for
10+
> anything live.
1011
1112
A crosspoint-based [SRT](https://www.srtalliance.org/) router: any number of
1213
SRT inputs, any number of SRT outputs, and a router-style crosspoint (each
@@ -44,19 +45,29 @@ hardware router's control port.
4445
- The crosspoint engine (`crates/core`) — output-follows-route-change
4546
behavior is unit tested.
4647
- A local web UI (`crates/web`) — grid of outputs x sources, click to route,
47-
polls `/api/state` once a second.
48-
- Verified locally: `cargo test` passes; running the binary against
49-
`config/example.toml` actually binds real UDP/SRT listener sockets
50-
(confirmed via `lsof`), the REST API drives real crosspoint state changes,
51-
and clicking a cell in the web UI (in a real browser) correctly re-routes
52-
an output.
53-
54-
**Not yet done:** no test against a real SRT encoder/decoder or over a real
55-
network (only loopback-adjacent local testing so far), no persistence of
56-
routing across a restart, no special-purpose sources (stills/media
57-
player/scaler), no auth on the web UI/API, no external control
58-
API/Companion integration. See [docs/roadmap.md](docs/roadmap.md) for the
59-
full phased plan.
48+
updated live over a websocket (`GET /ws`) with a REST poll (`GET
49+
/api/state`) as first paint / fallback.
50+
- Routing changes optionally persist to disk (`[state]` in the config) and
51+
reload on restart, overriding each output's `default_source`.
52+
- CI (GitHub Actions) runs `fmt --check`, `clippy -D warnings`, and the full
53+
test suite on every push/PR.
54+
- Verified locally, not just compiled: `cargo test` passes, including
55+
integration tests (`crates/srt-io/tests/relay.rs`) that relay real SRT
56+
protocol traffic end-to-end through the crosspoint using `srt-tokio`
57+
clients as the encoder/decoder — one test also exercises a **live
58+
re-route mid-stream over an already-established SRT connection**.
59+
Separately confirmed by hand: running the binary against
60+
`config/example.toml` binds real UDP/SRT listener sockets (via `lsof`),
61+
the REST API and a real browser click both drive live crosspoint changes,
62+
the websocket push updates the grid with no client-side polling, and a
63+
persisted route survives a real process restart.
64+
65+
**Not yet done:** no test against a real third-party SRT
66+
encoder/decoder or over a real (non-loopback) network path — only local
67+
testing so far, still the main open gap. Also missing: special-purpose
68+
sources (stills/media player/scaler), auth on the web UI/API, external
69+
control API/Companion integration. See [docs/roadmap.md](docs/roadmap.md)
70+
for the full phased plan.
6071

6172
## Quick start
6273

crates/web/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ license.workspace = true
66

77
[dependencies]
88
crosspoint-core = { path = "../core" }
9-
axum = "0.7"
9+
axum = { version = "0.7", features = ["ws"] }
1010
tokio = { version = "1", features = ["full"] }
1111
serde = { version = "1", features = ["derive"] }
1212
serde_json = "1"

crates/web/src/lib.rs

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@
55
66
use std::net::SocketAddr;
77
use std::sync::Arc;
8+
use std::time::Duration;
89

10+
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
911
use axum::extract::State;
10-
use axum::response::Html;
12+
use axum::response::{Html, IntoResponse};
1113
use axum::routing::{get, post};
1214
use axum::{Json, Router};
1315
use crosspoint_core::Crosspoint;
1416
use serde::{Deserialize, Serialize};
1517

1618
const INDEX_HTML: &str = include_str!("../static/index.html");
1719

20+
/// How often the websocket handler checks for a routing change to push.
21+
/// Polling (rather than a change-hook on `crosspoint-core`) keeps the core
22+
/// engine free of any web-specific API — same tradeoff the REST endpoint
23+
/// and the router binary's own state-persistence task already make.
24+
const PUSH_POLL_INTERVAL: Duration = Duration::from_millis(200);
25+
1826
#[derive(Serialize)]
1927
struct StateResponse {
2028
sources: Vec<String>,
@@ -33,16 +41,20 @@ struct RouteResponse {
3341
ok: bool,
3442
}
3543

44+
fn snapshot(crosspoint: &Crosspoint) -> StateResponse {
45+
StateResponse {
46+
sources: crosspoint.source_ids(),
47+
outputs: crosspoint.output_ids(),
48+
routes: crosspoint.routes(),
49+
}
50+
}
51+
3652
async fn index() -> Html<&'static str> {
3753
Html(INDEX_HTML)
3854
}
3955

4056
async fn get_state(State(crosspoint): State<Arc<Crosspoint>>) -> Json<StateResponse> {
41-
Json(StateResponse {
42-
sources: crosspoint.source_ids(),
43-
outputs: crosspoint.output_ids(),
44-
routes: crosspoint.routes(),
45-
})
57+
Json(snapshot(&crosspoint))
4658
}
4759

4860
async fn post_route(
@@ -53,11 +65,46 @@ async fn post_route(
5365
Json(RouteResponse { ok })
5466
}
5567

68+
async fn ws_handler(
69+
ws: WebSocketUpgrade,
70+
State(crosspoint): State<Arc<Crosspoint>>,
71+
) -> impl IntoResponse {
72+
ws.on_upgrade(move |socket| push_state(socket, crosspoint))
73+
}
74+
75+
/// Push the crosspoint state to the client on connect and again every time
76+
/// it changes, so the grid updates live without waiting on its own poll.
77+
async fn push_state(mut socket: WebSocket, crosspoint: Arc<Crosspoint>) {
78+
let mut last: Option<String> = None;
79+
loop {
80+
let Ok(body) = serde_json::to_string(&snapshot(&crosspoint)) else {
81+
return;
82+
};
83+
if last.as_deref() != Some(body.as_str()) {
84+
if socket.send(Message::Text(body.clone())).await.is_err() {
85+
return;
86+
}
87+
last = Some(body);
88+
}
89+
tokio::select! {
90+
_ = tokio::time::sleep(PUSH_POLL_INTERVAL) => {}
91+
msg = socket.recv() => {
92+
// The client doesn't send anything meaningful; only its
93+
// disconnect (None) or an error needs to stop this task.
94+
if !matches!(msg, Some(Ok(_))) {
95+
return;
96+
}
97+
}
98+
}
99+
}
100+
}
101+
56102
fn app(crosspoint: Arc<Crosspoint>) -> Router {
57103
Router::new()
58104
.route("/", get(index))
59105
.route("/api/state", get(get_state))
60106
.route("/api/route", post(post_route))
107+
.route("/ws", get(ws_handler))
61108
.with_state(crosspoint)
62109
}
63110

crates/web/static/index.html

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,23 @@ <h1>SRT Router — Crosspoint</h1>
9595
}
9696
}
9797

98-
tick();
99-
setInterval(tick, 1000);
98+
const RECONNECT_DELAY_MS = 2000;
99+
100+
function connectLive() {
101+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
102+
const ws = new WebSocket(`${proto}//${location.host}/ws`);
103+
ws.addEventListener('open', () => setStatus(''));
104+
ws.addEventListener('message', (ev) => render(JSON.parse(ev.data)));
105+
ws.addEventListener('close', () => {
106+
setStatus('Disconnected, retrying…');
107+
setTimeout(connectLive, RECONNECT_DELAY_MS);
108+
});
109+
ws.addEventListener('error', () => ws.close());
110+
}
111+
112+
// First paint over plain REST (fast, works even if the websocket upgrade is
113+
// ever blocked by something in between), then switch to live push.
114+
tick().finally(connectLive);
100115
</script>
101116
</body>
102117
</html>

docs/architecture.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ at the cost of actually running a media pipeline for that one path — see
7676
`spawn_input`/`spawn_output`, each owning one socket and one reconnect
7777
loop (`listener`: re-`listen_on`; `caller`: re-`call`).
7878
- `crates/web` (`crosspoint-web`) — an `axum` server: `GET /api/state`,
79-
`POST /api/route`, and a single embedded HTML/JS page
80-
(`crates/web/static/index.html`) that polls `/api/state` once a second and
81-
renders the routing grid. No websocket push yet — see roadmap.
79+
`POST /api/route`, `GET /ws` (websocket push — sends the current state on
80+
connect and again whenever it changes), and a single embedded HTML/JS
81+
page (`crates/web/static/index.html`) that does one REST fetch for first
82+
paint, then switches to the websocket for live updates (falling back to a
83+
2s reconnect loop if the connection drops).
8284
- `crates/router` (bin `srtrouter`) — loads a TOML config
8385
([config/example.toml](../config/example.toml)), registers every
8486
configured input/output with a shared `Crosspoint`, and starts the web
@@ -101,5 +103,13 @@ connect = "203.0.113.10:5000"
101103
```
102104

103105
Outputs are the same shape plus `default_source` (what they're routed from
104-
at startup — routing changes made afterward via the web UI/API are
105-
in-memory only and don't persist across a restart yet, see roadmap).
106+
at startup if nothing better is known — see below).
107+
108+
Optionally, a top-level `[state]` section with a `path` enables persisting
109+
routing changes to disk: every time a route changes, the full output ->
110+
source table is written to that JSON file (write-then-rename, so a crash
111+
mid-write can't leave a corrupt file behind); on startup, any persisted
112+
route for a configured output overrides that output's `default_source`.
113+
Omit `[state]` to keep routing in-memory only, as before — every restart
114+
then resets to each output's `default_source`. See
115+
`crates/router/src/state.rs`.

0 commit comments

Comments
 (0)