|
| 1 | +# Live edits over a stream |
| 2 | + |
| 3 | +Tutorial 01 drove a graph at the browser's animation-frame rate. The |
| 4 | +clock said "go" 60 times a second, every actor woke up, the canvas |
| 5 | +got painted. That is one shape of reactive work. |
| 6 | + |
| 7 | +The other shape is **data arriving when it arrives**. A WebSocket |
| 8 | +push, a Server-Sent Events feed, a `fetch` whose body keeps streaming |
| 9 | +after the response headers land. The graph still runs only when its |
| 10 | +inputs change, but the trigger is the network instead of the clock. |
| 11 | + |
| 12 | +This tutorial wires a Reflow graph to Wikimedia's public stream of |
| 13 | +Wikipedia edits. As people edit articles, the graph filters and |
| 14 | +displays them. No animation frame, no polling, no `setInterval`. The |
| 15 | +network drives the graph. |
| 16 | + |
| 17 | +## What we are building |
| 18 | + |
| 19 | +```mermaid |
| 20 | +flowchart LR |
| 21 | + sse([Wikimedia SSE]) -->|event| source[source] |
| 22 | + source -->|event| filter[substantive?] |
| 23 | + filter -->|event| display[display] |
| 24 | +``` |
| 25 | + |
| 26 | +Three actors. `source` opens an `EventSource` to Wikimedia and emits |
| 27 | +each parsed JSON event. `filter` keeps only edits to en.wikipedia |
| 28 | +articles by humans whose byte-delta is at least ±200 (skip stubs and |
| 29 | +typo fixes). `display` puts each surviving event at the top of a |
| 30 | +list on the page. |
| 31 | + |
| 32 | +If you have ever written `fetch().then(r => r.body.getReader())` or |
| 33 | +`new EventSource(url)`, you know the input shape already. |
| 34 | + |
| 35 | +## Setup |
| 36 | + |
| 37 | +One file in any directory. |
| 38 | + |
| 39 | +```html |
| 40 | +<!doctype html> |
| 41 | +<meta charset="utf-8"> |
| 42 | +<title>Live Wikipedia edits</title> |
| 43 | +<style> |
| 44 | + body { margin: 0; background: #0b1020; color: #c9d2e6; |
| 45 | + font: 14px/1.5 system-ui; padding: 24px 32px; } |
| 46 | + ol { list-style: none; padding: 0; max-width: 720px; } |
| 47 | + li { padding: 8px 12px; margin: 6px 0; background: #131a30; border-radius: 4px; } |
| 48 | +</style> |
| 49 | +<ol id="feed"></ol> |
| 50 | + |
| 51 | +<script type="module"> |
| 52 | +import { ready, Network, Actor, Message } |
| 53 | + from "https://esm.sh/@offbit-ai/reflow"; |
| 54 | +
|
| 55 | +await ready(); |
| 56 | +// the rest goes here |
| 57 | +</script> |
| 58 | +``` |
| 59 | + |
| 60 | +The Wikimedia stream is public and CORS-friendly, so the page does |
| 61 | +not need a server beyond a static file server. |
| 62 | + |
| 63 | +## The actors |
| 64 | + |
| 65 | +### Source |
| 66 | + |
| 67 | +The interesting one. Owns an `EventSource` and bridges it into the |
| 68 | +graph. The bridge is an internal queue: events arrive whenever the |
| 69 | +network pushes them, but the actor only fires `ctx.send` when the |
| 70 | +runtime calls `run(ctx)`. |
| 71 | + |
| 72 | +```js |
| 73 | +class Source extends Actor { |
| 74 | + static inports = []; |
| 75 | + static outports = ["event"]; |
| 76 | + |
| 77 | + constructor(url) { |
| 78 | + super(); |
| 79 | + this.queue = []; |
| 80 | + this.resume = null; |
| 81 | + const es = new EventSource(url); |
| 82 | + es.addEventListener("message", (e) => { |
| 83 | + try { |
| 84 | + this.queue.push(JSON.parse(e.data)); |
| 85 | + this.resume?.(); |
| 86 | + this.resume = null; |
| 87 | + } catch { /* drop malformed lines */ } |
| 88 | + }); |
| 89 | + } |
| 90 | + |
| 91 | + run(ctx) { |
| 92 | + const send = () => { |
| 93 | + ctx.send({ event: Message.object(this.queue.shift()) }); |
| 94 | + ctx.done(); |
| 95 | + }; |
| 96 | + if (this.queue.length) send(); |
| 97 | + else this.resume = send; |
| 98 | + } |
| 99 | +} |
| 100 | +``` |
| 101 | +
|
| 102 | +Two states. If the queue has events, fire one and move on. If the |
| 103 | +queue is empty, park the run by stashing the continuation in |
| 104 | +`this.resume`; the next inbound EventSource message resumes it. |
| 105 | +
|
| 106 | +That pattern — actor-as-source-with-queue — is the canonical way to |
| 107 | +plug any push-based input (sockets, EventSource, observers, native |
| 108 | +events) into a Reflow graph. The runtime decides how fast to drain |
| 109 | +the queue based on what is downstream. If `display` is slow, the |
| 110 | +queue grows; the rest of the graph does not stall. |
| 111 | +
|
| 112 | +### Filter |
| 113 | +
|
| 114 | +A pure transform. Receives an event, checks a predicate, forwards if |
| 115 | +it passes. |
| 116 | +
|
| 117 | +```js |
| 118 | +class Filter extends Actor { |
| 119 | + static inports = ["event"]; |
| 120 | + static outports = ["event"]; |
| 121 | + |
| 122 | + constructor(predicate) { super(); this.predicate = predicate; } |
| 123 | + |
| 124 | + run(ctx) { |
| 125 | + const event = ctx.input.event?.data; |
| 126 | + if (event && this.predicate(event)) { |
| 127 | + ctx.send({ event: Message.object(event) }); |
| 128 | + } |
| 129 | + ctx.done(); |
| 130 | + } |
| 131 | +} |
| 132 | +``` |
| 133 | +
|
| 134 | +The predicate is injected at construction time, which keeps the actor |
| 135 | +generic. The same `Filter` class can sit in any pipeline. |
| 136 | +
|
| 137 | +### Display |
| 138 | +
|
| 139 | +Renders. Each event becomes one `<li>` at the top of the list. Old |
| 140 | +entries fade and the list caps at 50. |
| 141 | +
|
| 142 | +```js |
| 143 | +class Display extends Actor { |
| 144 | + static inports = ["event"]; |
| 145 | + static outports = []; |
| 146 | + |
| 147 | + constructor(list, max = 50) { |
| 148 | + super(); |
| 149 | + this.list = list; |
| 150 | + this.max = max; |
| 151 | + } |
| 152 | + |
| 153 | + run(ctx) { |
| 154 | + const e = ctx.input.event?.data; |
| 155 | + if (e) { |
| 156 | + const li = document.createElement("li"); |
| 157 | + const delta = (e.length?.new ?? 0) - (e.length?.old ?? 0); |
| 158 | + li.textContent = `${delta >= 0 ? "+" : ""}${delta} ${e.title} — ${e.user}`; |
| 159 | + this.list.prepend(li); |
| 160 | + while (this.list.children.length > this.max) this.list.lastChild.remove(); |
| 161 | + } |
| 162 | + ctx.done(); |
| 163 | + } |
| 164 | +} |
| 165 | +``` |
| 166 | +
|
| 167 | +Same pattern as the `Draw` actor in tutorial 01. The DOM is the |
| 168 | +side-effect target; the rest of the graph does not know it exists. |
| 169 | +
|
| 170 | +## Wiring |
| 171 | +
|
| 172 | +```js |
| 173 | +const STREAM = "https://stream.wikimedia.org/v2/stream/recentchange"; |
| 174 | + |
| 175 | +const substantive = (e) => |
| 176 | + e.wiki === "enwiki" && |
| 177 | + e.namespace === 0 && |
| 178 | + !e.bot && |
| 179 | + Math.abs((e.length?.new ?? 0) - (e.length?.old ?? 0)) >= 200; |
| 180 | + |
| 181 | +const net = new Network(); |
| 182 | + |
| 183 | +net.addNode("source", "tpl_wikipedia_source"); |
| 184 | +net.addNode("filter", "tpl_substantive"); |
| 185 | +net.addNode("display", "tpl_display"); |
| 186 | + |
| 187 | +net.addConnection("source", "event", "filter", "event"); |
| 188 | +net.addConnection("filter", "event", "display", "event"); |
| 189 | + |
| 190 | +net.registerActor("tpl_wikipedia_source", new Source(STREAM)); |
| 191 | +net.registerActor("tpl_substantive", new Filter(substantive)); |
| 192 | +net.registerActor("tpl_display", new Display(document.getElementById("feed"))); |
| 193 | + |
| 194 | +await net.start(); |
| 195 | +``` |
| 196 | +
|
| 197 | +Two connections. No clock, no mouse, no `requestAnimationFrame`. The |
| 198 | +graph is dormant until the source has an event to push. |
| 199 | +
|
| 200 | +## Run it |
| 201 | +
|
| 202 | +```sh |
| 203 | +npx serve . |
| 204 | +``` |
| 205 | +
|
| 206 | +Open the page. Within a few seconds, substantive edits will start |
| 207 | +scrolling in. Some are vandalism, most are real work. Click a title |
| 208 | +to open the article. |
| 209 | +
|
| 210 | +The full example for this tutorial lives at |
| 211 | +[sdk/node/examples/tutorial-02-live-edits](https://github.com/offbit-ai/reflow/tree/main/sdk/node/examples/tutorial-02-live-edits) |
| 212 | +in the repo, with a slightly nicer style sheet and per-row links. |
| 213 | +
|
| 214 | +## What changed from tutorial 01 |
| 215 | +
|
| 216 | +In tutorial 01 the leftmost actor was a clock — a self-pacing source. |
| 217 | +Here the leftmost actor is an EventSource bridge — a network-paced |
| 218 | +source. The graph shape and the wiring are the same. What changes is |
| 219 | +**who decides when to fire**. |
| 220 | +
|
| 221 | +That is the whole point of the actor model carrying its own pacing |
| 222 | +information. A clock-driven source picks 60 Hz. A network-driven |
| 223 | +source picks "whenever the upstream sends." A user-driven source |
| 224 | +picks "whenever a click happens." Each is a different actor; each |
| 225 | +slots into the same kind of graph. |
| 226 | +
|
| 227 | +If you swap the predicate, the same code shows you whatever subset of |
| 228 | +the firehose interests you. Drop the `enwiki` check and you see every |
| 229 | +language. Drop the `namespace === 0` check and you see talk-page |
| 230 | +arguments and meta edits. Add `e.user.includes("bot")` and you see |
| 231 | +only bots. The graph, the actors, the imports — none of it changes. |
| 232 | +
|
| 233 | +## What is next |
| 234 | +
|
| 235 | +The next post moves languages. We will build a Python tutorial pairing |
| 236 | +Reflow with LangGraph: a video summary tool whose deterministic data |
| 237 | +work is a Reflow flow, called from inside an LLM agent. The runtime |
| 238 | +is the same Rust core; the SDK is what changes. |
0 commit comments