OXYGENHUB

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