Live pages L2: GET /live?topics= and the hub.js client 67be8ac00c24
One stream per page: up to 16 topics, each one the viewer may hear (read
access to its repository, or being the user of user:<id>), else refused
whole; change events {topic, version, kind} with the bus id as the event
id; Last-Event-ID replays what was missed on those topics or says reload
(also when the stream falls behind). hub.js refetches the ?fragment= of
elements marked data-live/data-fragment and swaps it, waiting while
someone types inside. Tests: topic parsing; the stream replays only its
topics after the id, refuses a private or another user's topic and bad
topics, and says reload when too far behind. docs/live.md 5a.
Closes #204
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 05:18 UTC
- Signature
- ✔ signed by Claude Code
- Hash
67be8ac00c24637b7b82d659fedbd481c48a17939e6ac1a61454ba2acb575531- Depends on
- 3dd6582de060 417184c73f4e 42fa95540468 7e01a7665aa5 c0e39717895d c9aacc0e7dc1 d1727c573f30 d23464c0ac60
- Depended on by
- 33d98147511b e97500b70f9a 119fd17d213f c92a99490cba 47e374020b59 a2ef53c5db26 b5a0a69b3622 f6529a2fb9db
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 6 atoms, 1 tree operation, 5 files
Browse the tree at this change
added crates/hub/src/web/live_stream.rs +146 −0
| 1 | + | //! `GET /live?topics=`: one stream of changes for a page (docs/live.md §4, |
|
| 2 | + | //! oxygenhub#204). Every topic must be one the viewer may read, or nothing |
|
| 3 | + | //! is streamed; an event names a topic, its version and a kind, never data, |
|
| 4 | + | //! and the page fetches what changed from its normal routes. |
|
| 5 | + | ||
| 6 | + | use crate::auth::Viewer; |
|
| 7 | + | use crate::db; |
|
| 8 | + | use crate::events::{LiveEvent, Replay}; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use axum::extract::{Query, State}; |
|
| 11 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 12 | + | use axum::response::sse::{Event, KeepAlive, Sse}; |
|
| 13 | + | use axum::response::{IntoResponse, Response}; |
|
| 14 | + | use serde::Deserialize; |
|
| 15 | + | ||
| 16 | + | /// The most topics one page asks for. |
|
| 17 | + | const MAX_TOPICS: usize = 16; |
|
| 18 | + | ||
| 19 | + | /// A topic, read (docs/live.md §4). |
|
| 20 | + | #[derive(Debug, PartialEq, Eq)] |
|
| 21 | + | pub enum Topic { |
|
| 22 | + | Repo(i64), |
|
| 23 | + | Proposal(i64, i64), |
|
| 24 | + | Issue(i64, i64), |
|
| 25 | + | Run(i64), |
|
| 26 | + | User(i64), |
|
| 27 | + | Diffusion(i64), |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | pub fn parse_topic(s: &str) -> Option<Topic> { |
|
| 31 | + | let num = |v: &str| v.parse::<i64>().ok().filter(|n| *n > 0); |
|
| 32 | + | if let Some(rest) = s.strip_prefix("repo:") { |
|
| 33 | + | return match rest.split_once('/') { |
|
| 34 | + | None => num(rest).map(Topic::Repo), |
|
| 35 | + | Some((id, sub)) => { |
|
| 36 | + | let id = num(id)?; |
|
| 37 | + | if let Some(n) = sub.strip_prefix("proposal:") { |
|
| 38 | + | num(n).map(|n| Topic::Proposal(id, n)) |
|
| 39 | + | } else if let Some(n) = sub.strip_prefix("issue:") { |
|
| 40 | + | num(n).map(|n| Topic::Issue(id, n)) |
|
| 41 | + | } else { |
|
| 42 | + | None |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | }; |
|
| 46 | + | } |
|
| 47 | + | if let Some(id) = s.strip_prefix("run:") { |
|
| 48 | + | return num(id).map(Topic::Run); |
|
| 49 | + | } |
|
| 50 | + | if let Some(id) = s.strip_prefix("user:") { |
|
| 51 | + | return num(id).map(Topic::User); |
|
| 52 | + | } |
|
| 53 | + | s.strip_prefix("diffusion:").and_then(num).map(Topic::Diffusion) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// Whether `viewer` may hear of changes to `topic`: read access to its |
|
| 57 | + | /// repository, or, for `user:`, being that user. |
|
| 58 | + | async fn may_hear(app: &Shared, viewer: &Viewer, topic: &Topic) -> bool { |
|
| 59 | + | let repo_id = match topic { |
|
| 60 | + | Topic::Repo(r) | Topic::Proposal(r, _) | Topic::Issue(r, _) | Topic::Diffusion(r) => *r, |
|
| 61 | + | Topic::Run(run) => match db::flow::run_by_id(&app.db, *run).await { |
|
| 62 | + | Ok(Some(r)) => r.repo_id, |
|
| 63 | + | _ => return false, |
|
| 64 | + | }, |
|
| 65 | + | Topic::User(id) => return viewer.account().is_some_and(|a| a.id == *id), |
|
| 66 | + | }; |
|
| 67 | + | let Ok(Some(row)) = db::repos::get_by_id(&app.db, repo_id).await else { return false }; |
|
| 68 | + | matches!(crate::auth::roles::role_for(&app.db, viewer.account(), &row).await, Ok(Some(_))) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | #[derive(Deserialize)] |
|
| 72 | + | pub struct LiveQuery { |
|
| 73 | + | #[serde(default)] |
|
| 74 | + | topics: String, |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | fn sse_event(e: &LiveEvent) -> Event { |
|
| 78 | + | Event::default() |
|
| 79 | + | .id(e.id.to_string()) |
|
| 80 | + | .event("change") |
|
| 81 | + | .data(serde_json::json!({ "topic": e.topic, "version": e.version, "kind": e.kind }).to_string()) |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// `GET /live?topics=repo:1,repo:1/proposal:4`. |
|
| 85 | + | pub async fn stream(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap, Query(q): Query<LiveQuery>) -> Response { |
|
| 86 | + | use futures::StreamExt; |
|
| 87 | + | let mut topics: Vec<String> = q.topics.split(',').map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect(); |
|
| 88 | + | topics.sort(); |
|
| 89 | + | topics.dedup(); |
|
| 90 | + | if topics.is_empty() || topics.len() > MAX_TOPICS { |
|
| 91 | + | return (StatusCode::BAD_REQUEST, format!("ask for 1 to {MAX_TOPICS} topics, as /live?topics=repo:1")).into_response(); |
|
| 92 | + | } |
|
| 93 | + | for t in &topics { |
|
| 94 | + | let Some(topic) = parse_topic(t) else { |
|
| 95 | + | return (StatusCode::BAD_REQUEST, format!("{t:?} is not a topic")).into_response(); |
|
| 96 | + | }; |
|
| 97 | + | if !may_hear(&app, &viewer, &topic).await { |
|
| 98 | + | // The same answer for every topic one may not hear: whether it |
|
| 99 | + | // exists is not said either. |
|
| 100 | + | return (StatusCode::FORBIDDEN, "not every topic asked for may be heard").into_response(); |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | // Subscribed before the replay is read, so nothing falls between them. |
|
| 104 | + | let rx = app.events.live.subscribe(); |
|
| 105 | + | let last: Option<u64> = headers.get("last-event-id").and_then(|v| v.to_str().ok()).and_then(|v| v.parse().ok()); |
|
| 106 | + | let (replay, after) = match last { |
|
| 107 | + | Some(id) => match app.events.live.since(id, &topics) { |
|
| 108 | + | Replay::Events(e) => { |
|
| 109 | + | let after = e.last().map(|e| e.id).unwrap_or(id); |
|
| 110 | + | (e.iter().map(|e| Ok(sse_event(e))).collect::<Vec<_>>(), after) |
|
| 111 | + | } |
|
| 112 | + | Replay::Reload => (vec![Ok(Event::default().event("reload").data("{}"))], app.events.live.last_id()), |
|
| 113 | + | }, |
|
| 114 | + | None => (vec![], app.events.live.last_id()), |
|
| 115 | + | }; |
|
| 116 | + | let live = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| { |
|
| 117 | + | let topics = topics.clone(); |
|
| 118 | + | async move { |
|
| 119 | + | match item { |
|
| 120 | + | Ok(e) if e.id > after && topics.contains(&e.topic) => Some(Ok::<_, std::convert::Infallible>(sse_event(&e))), |
|
| 121 | + | // A subscriber that fell behind is told to reload. |
|
| 122 | + | Err(_) => Some(Ok(Event::default().event("reload").data("{}"))), |
|
| 123 | + | _ => None, |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | }); |
|
| 127 | + | Sse::new(futures::stream::iter(replay).chain(live)).keep_alive(KeepAlive::default()).into_response() |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | #[cfg(test)] |
|
| 131 | + | mod tests { |
|
| 132 | + | use super::*; |
|
| 133 | + | ||
| 134 | + | #[test] |
|
| 135 | + | fn topics_are_read_strictly() { |
|
| 136 | + | assert_eq!(parse_topic("repo:7"), Some(Topic::Repo(7))); |
|
| 137 | + | assert_eq!(parse_topic("repo:7/proposal:4"), Some(Topic::Proposal(7, 4))); |
|
| 138 | + | assert_eq!(parse_topic("repo:7/issue:9"), Some(Topic::Issue(7, 9))); |
|
| 139 | + | assert_eq!(parse_topic("run:12"), Some(Topic::Run(12))); |
|
| 140 | + | assert_eq!(parse_topic("user:3"), Some(Topic::User(3))); |
|
| 141 | + | assert_eq!(parse_topic("diffusion:7"), Some(Topic::Diffusion(7))); |
|
| 142 | + | for bad in ["repo:", "repo:x", "repo:-1", "repo:7/wiki:1", "repo:7/proposal:", "team:1", "", "run:0"] { |
|
| 143 | + | assert_eq!(parse_topic(bad), None, "{bad}"); |
|
| 144 | + | } |
|
| 145 | + | } |
|
| 146 | + | } |
modified assets/hub.js +51 −0
| … | 709 unchanged lines |
||
| 710 | 710 | }); |
|
| 711 | 711 | }); |
|
| 712 | 712 | })(); |
|
| 713 | + | ||
| 714 | + | // Live pages (docs/live.md, oxygenhub#204): elements marked |
|
| 715 | + | // data-live="<topic>" data-fragment="<name>" are fetched again from this |
|
| 716 | + | // page's own route (?fragment=<name>) when the hub says their topic |
|
| 717 | + | // changed, and swapped in. Someone typing inside one is not interrupted: |
|
| 718 | + | // it is refreshed when they leave it. Without JavaScript or a stream the |
|
| 719 | + | // page is as it was; a reload shows everything. |
|
| 720 | + | (function () { |
|
| 721 | + | var marked = document.querySelectorAll("[data-live][data-fragment]"); |
|
| 722 | + | if (!marked.length || !window.EventSource || !window.fetch) return; |
|
| 723 | + | var topics = {}; |
|
| 724 | + | Array.prototype.forEach.call(marked, function (el) { topics[el.getAttribute("data-live")] = true; }); |
|
| 725 | + | var busy = {}; |
|
| 726 | + | var typingIn = function (el) { |
|
| 727 | + | var a = document.activeElement; |
|
| 728 | + | return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value; |
|
| 729 | + | }; |
|
| 730 | + | var refresh = function (el) { |
|
| 731 | + | var name = el.getAttribute("data-fragment"); |
|
| 732 | + | if (busy[name]) return; |
|
| 733 | + | if (typingIn(el)) { |
|
| 734 | + | el.addEventListener("focusout", function again() { |
|
| 735 | + | el.removeEventListener("focusout", again); |
|
| 736 | + | setTimeout(function () { refresh(el); }, 0); |
|
| 737 | + | }); |
|
| 738 | + | return; |
|
| 739 | + | } |
|
| 740 | + | busy[name] = true; |
|
| 741 | + | var url = new URL(location.href); |
|
| 742 | + | url.searchParams.set("fragment", name); |
|
| 743 | + | fetch(url.toString(), { credentials: "same-origin", headers: { "X-Fragment": name } }) |
|
| 744 | + | .then(function (r) { return r.ok ? r.text() : null; }) |
|
| 745 | + | .then(function (html) { |
|
| 746 | + | busy[name] = false; |
|
| 747 | + | if (html !== null && !typingIn(el)) el.innerHTML = html; |
|
| 748 | + | }) |
|
| 749 | + | .catch(function () { busy[name] = false; }); |
|
| 750 | + | }; |
|
| 751 | + | var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(","))); |
|
| 752 | + | es.addEventListener("change", function (e) { |
|
| 753 | + | var d; |
|
| 754 | + | try { d = JSON.parse(e.data); } catch (err) { return; } |
|
| 755 | + | Array.prototype.forEach.call(marked, function (el) { |
|
| 756 | + | if (el.getAttribute("data-live") === d.topic) refresh(el); |
|
| 757 | + | }); |
|
| 758 | + | }); |
|
| 759 | + | // Missed more than the hub keeps: everything marked is fetched again. |
|
| 760 | + | es.addEventListener("reload", function () { |
|
| 761 | + | Array.prototype.forEach.call(marked, refresh); |
|
| 762 | + | }); |
|
| 763 | + | })(); |
modified crates/hub/src/web/mod.rs +2 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub mod history; |
|
| 20 | 20 | pub mod home; |
|
| 21 | 21 | pub mod icons; |
|
| 22 | + | mod live_stream; |
|
| 22 | 23 | mod security_txt; |
|
| 23 | 24 | ||
| 24 | 25 | /// A state as people read it (oxygenhub#170): `02a6c9d4746a+… (14 heads)` |
|
| … | 56 unchanged lines |
||
| 81 | 82 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 82 | 83 | .route("/.well-known/openid-configuration", get(crate::oidc::discovery)) |
|
| 83 | 84 | .route("/.well-known/jwks.json", get(crate::oidc::jwks)) |
|
| 85 | + | .route("/live", get(live_stream::stream)) |
|
| 84 | 86 | .route("/.well-known/security.txt", get(security_txt::serve)) |
|
| 85 | 87 | .route("/security.txt", get(security_txt::serve)) |
|
| 86 | 88 | .route("/style.css", get(statics::style)) |
|
| … | 700 unchanged lines |
modified crates/hub/tests/flow.rs +54 −0
| … | 4384 unchanged lines |
||
| 4385 | 4385 | let apply = jobs.iter().find(|j| j.name == "apply").unwrap(); |
|
| 4386 | 4386 | assert_eq!((apply.status.as_str(), apply.error.as_str()), ("cancelled", "no approval within 24h")); |
|
| 4387 | 4387 | } |
|
| 4388 | + | ||
| 4389 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4390 | + | async fn live_streams_only_topics_the_viewer_may_hear_and_replays() { |
|
| 4391 | + | let h = hub().await; |
|
| 4392 | + | create(&h.app, "andy", "open").await; |
|
| 4393 | + | h.app.repos.create(&RepoName::parse("andy/shut").unwrap(), None).unwrap(); |
|
| 4394 | + | db::repos::create(&h.app.db, "andy", "shut", "", hub_api::Visibility::Private).await.unwrap(); |
|
| 4395 | + | let open = db::repos::get(&h.app.db, "andy", "open").await.unwrap().unwrap(); |
|
| 4396 | + | let shut = db::repos::get(&h.app.db, "andy", "shut").await.unwrap().unwrap(); |
|
| 4397 | + | let live = &h.app.events.live; |
|
| 4398 | + | let first = live.publish(&oxygenhub::events::topic::repo(open.id), "push"); |
|
| 4399 | + | live.publish(&oxygenhub::events::topic::repo(shut.id), "push"); |
|
| 4400 | + | live.publish(&oxygenhub::events::topic::proposal(open.id, 3), "review"); |
|
| 4401 | + | ||
| 4402 | + | // The first bytes of a stream, within a moment. |
|
| 4403 | + | let read = |path: String, last: Option<u64>| { |
|
| 4404 | + | let app = h.app.clone(); |
|
| 4405 | + | async move { |
|
| 4406 | + | let mut req = Request::get(&path); |
|
| 4407 | + | if let Some(l) = last { |
|
| 4408 | + | req = req.header("last-event-id", l.to_string()); |
|
| 4409 | + | } |
|
| 4410 | + | let res = oxygenhub::router(app).oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); |
|
| 4411 | + | let status = res.status(); |
|
| 4412 | + | let mut body = res.into_body(); |
|
| 4413 | + | let mut text = String::new(); |
|
| 4414 | + | while let Ok(Some(Ok(frame))) = tokio::time::timeout(std::time::Duration::from_millis(300), body.frame()).await { |
|
| 4415 | + | if let Some(d) = frame.data_ref() { |
|
| 4416 | + | text.push_str(&String::from_utf8_lossy(d)); |
|
| 4417 | + | } |
|
| 4418 | + | } |
|
| 4419 | + | (status, text) |
|
| 4420 | + | } |
|
| 4421 | + | }; |
|
| 4422 | + | let topics = format!("/live?topics=repo:{},repo:{}/proposal:3", open.id, open.id); |
|
| 4423 | + | // From the first event on: what came after it on these topics only. |
|
| 4424 | + | let (st, text) = read(topics.clone(), Some(first.id)).await; |
|
| 4425 | + | assert_eq!(st, StatusCode::OK); |
|
| 4426 | + | assert!(text.contains("event: change") && text.contains(&format!("\"topic\":\"repo:{}/proposal:3\"", open.id)), "{text}"); |
|
| 4427 | + | assert!(!text.contains(&format!("\"topic\":\"repo:{}\"", shut.id)), "never another topic: {text}"); |
|
| 4428 | + | assert!(!text.contains(&format!("id: {}\n", first.id)), "not what was already seen: {text}"); |
|
| 4429 | + | // A private repository's topic, or someone else's, is refused whole. |
|
| 4430 | + | let (st, _) = read(format!("/live?topics=repo:{},repo:{}", open.id, shut.id), None).await; |
|
| 4431 | + | assert_eq!(st, StatusCode::FORBIDDEN); |
|
| 4432 | + | assert_eq!(read("/live?topics=user:1".into(), None).await.0, StatusCode::FORBIDDEN); |
|
| 4433 | + | assert_eq!(read("/live?topics=nonsense".into(), None).await.0, StatusCode::BAD_REQUEST); |
|
| 4434 | + | assert_eq!(read("/live?topics=".into(), None).await.0, StatusCode::BAD_REQUEST); |
|
| 4435 | + | // Too far behind: told to reload. |
|
| 4436 | + | for _ in 0..oxygenhub::events::REPLAY { |
|
| 4437 | + | live.publish("repo:999999", "push"); |
|
| 4438 | + | } |
|
| 4439 | + | let (_, text) = read(topics, Some(first.id)).await; |
|
| 4440 | + | assert!(text.contains("event: reload"), "{text}"); |
|
| 4441 | + | } |
modified docs/live.md +10 −0
| … | 101 unchanged lines |
||
| 102 | 102 | event names), `notify::tell` (`user:<id>`), Flow job changes |
|
| 103 | 103 | (`run:<id>`) and Diffusion bumps (`diffusion:<repo id>`, both sides). |
|
| 104 | 104 | ||
| 105 | + | - **L2 (done, #204):** `GET /live?topics=a,b` (1 to 16 topics; every one |
|
| 106 | + | must be one the viewer may hear, read access to its repository or, for |
|
| 107 | + | `user:<id>`, being that user, or the request is refused whole, saying |
|
| 108 | + | nothing of which); SSE `change` events `{topic, version, kind}` with the |
|
| 109 | + | bus id as the event id; `Last-Event-ID` replays the missed events on |
|
| 110 | + | those topics or sends `reload`, as does falling behind the broadcast. |
|
| 111 | + | The client in `hub.js`: `data-live` + `data-fragment` elements refetch |
|
| 112 | + | `?fragment=<name>` from the page's own route and swap it in, waiting |
|
| 113 | + | while someone types inside one. |
|
| 114 | + | ||
| 105 | 115 | ## 6. Order |
|
| 106 | 116 | ||
| 107 | 117 | | Step | What | Issue | |
|
| … | 8 unchanged lines |