OXYGENHUB
# a2ef53c5db26 oxygenhub / docs / live.md
6.1 KiB · Markdown · last changed Live pages L2: GET /live?topics= and the hub.js client 20 days ago renderedsource blame history raw

Live pages

Status: design, 2026-10-14 (oxygenhub#202). Nothing here is built yet beyond what §2 lists.

1. The problem

Pages are rendered once. When something happens behind them (an assistant answers, a run finishes, a review arrives, a bump is opened) the page does not know until it is reloaded. Proposal 38 on doomsdayone/bomberman94 showed it: the assistant's answer, or the reason there was none, only appeared on reload.

2. What exists

  • Flow runs stream their logs and job states over Server-Sent Events (/{owner}/{name}/flow/{id}/live, read by EventSource in assets/hub.js).
  • The conflict resolver keeps in-memory rooms (crates/hub/src/live.rs): a tokio::sync::broadcast channel per conflict, for presence and choices.
  • Notifications and webhooks already describe most writes as events (notify::deliver, hooks::emit).

3. Not a single-page app

Moving to Next.js or another single-page app was considered and set aside:

  • every page would be rendered twice (Rust templates and JavaScript), and the two drift;
  • public pages would still need server rendering;
  • pages would stop working without JavaScript, and accessibility gets harder;
  • permissions would be checked in two places;
  • it rewrites about a hundred templates to gain one thing: pages that update themselves.

4. The design: notify, then fetch

  1. Events name a topic, not data. Every write publishes a small event: a topic, a version, and a kind. Topics:
    • repo:<id> (bookmarks moved, settings);
    • repo:<id>/proposal:<n> and repo:<id>/issue:<n> (comments, reviews, assistant answers, labels, state, checks);
    • run:<id> (jobs and logs, as today);
    • user:<id> (inbox, dashboard);
    • diffusion:<repo id> (bumps, pills).
  2. One stream per page. A page asks for its topics on one connection: GET /live?topics=..., text/event-stream. The browser reconnects by itself; Last-Event-ID resumes, and the server replays what was missed from a short buffer, or says "reload" when it cannot.
  3. The page fetches what changed. An event says which part of the page is stale. The page fetches that part as HTML from the normal route (?fragment=comments, ?fragment=checks and so on) and swaps it in. So:
    • the templates that render the page render the update;
    • permission is checked once, by the route that renders the fragment; an event reaching the wrong viewer reveals nothing but a topic and a version;
    • fragments can be cached and are tested like pages.
  4. Subscribing is checked too. /live only accepts topics the viewer may read (a private repository's topics need read access), so even the fact that something changed does not leak.
  5. The page still works without it. No JavaScript or no connection: the page is what it is today, and a reload shows everything. Pending work shows a placeholder ("Claude is reviewing…") that the stream replaces.
  6. A small client, ours. About 2 KB in hub.js: elements carry data-live="<topic>" and data-fragment="<name>"; an event for the topic refetches the fragment and swaps it, keeping focus, scroll and anything being typed. No framework.

5. At scale, across services

When the hub is split into services:

  • Services publish events to a durable log. NATS JetStream is the choice: small, self-hosted, clustered, with replay, so a sovereign site runs it on its own machines. Postgres LISTEN/NOTIFY is an acceptable first step for a single database.
  • A stateless live gateway holds the browsers' connections. It checks a viewer's subscription (asking the owning service, cached briefly), relays topic events, and replays from the log for Last-Event-ID. It never renders and never reads repositories, so it scales out on its own; losing an instance means browsers reconnect.
  • Fragments stay with the service that owns the data (issues, proposals, Flow, Diffusion). Moving a page to a new service does not touch the live layer.
  • The resolver's rooms become topics on the log, so people on different instances see each other.
  • Later: WebTransport where streams need to be bidirectional and many; SSE stays the default.

5a. Status

  • L1 (done, #203): events::LiveBus in the hub: one broadcast of {id, topic, version, kind} that subscribers filter by topic (a channel per topic buys nothing at this size), a version per topic, and the last 1024 events for Last-Event-ID (older: "reload"). Published from hooks::emit (the repository's topic, and the proposal's or issue's the event names), notify::tell (user:<id>), Flow job changes (run:<id>) and Diffusion bumps (diffusion:<repo id>, both sides).

  • L2 (done, #204): GET /live?topics=a,b (1 to 16 topics; every one must be one the viewer may hear, read access to its repository or, for user:<id>, being that user, or the request is refused whole, saying nothing of which); SSE change events {topic, version, kind} with the bus id as the event id; Last-Event-ID replays the missed events on those topics or sends reload, as does falling behind the broadcast. The client in hub.js: data-live + data-fragment elements refetch ?fragment=<name> from the page's own route and swap it in, waiting while someone types inside one.

6. Order

StepWhatIssue
L1Event bus inside the hub (broadcast per topic, versions, a short replay buffer); publish from the writes that notify and webhooks already see#203
L2GET /live?topics= with subscription checks, Last-Event-ID replay, and the hub.js client with data-live / data-fragment#204
L3The proposal page live: comments, reviews, assistant answers and pending placeholders, checks, landing state#205
L4Dashboard and inbox live, with the notification count in the top bar#206
L5Issue page, Diffusion tab and repository overview live#207
L6Fold the Flow run stream and the resolver's rooms into the same bus#208
L7Services: JetStream behind the bus and the live gateway as its own service#209