Skip to content

Commit 3353761

Browse files
committed
Replace tutorial 02 with a streams-driven Wikipedia edits feed
The WGSL ray-marching tutorial was too dense for the beginner-intermediate audience the series is aimed at. Most JS devs do not write shaders, so a tutorial that opens with smin and ray marching loses them before the actor model story lands. The streams shape pays off the series intro better and stays in territory every JS dev knows. We bridge an EventSource into a Reflow source actor, filter the firehose with a small predicate, and render to the DOM. The lesson — sources can be clock-driven, network-driven, or user-driven, and the graph does not care which — sits at exactly the same level as tutorial 01. The runnable example connects to Wikimedia's public recent-changes SSE endpoint with no auth or local server. Substantive edits (en.wikipedia, mainspace, non-bot, ±200 bytes minimum) scroll in as people make them.
1 parent 25708ec commit 3353761

7 files changed

Lines changed: 374 additions & 514 deletions

File tree

docs/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104

105105
- [Real-world Reflow](tutorials/real-world/README.md)
106106
- [Reactive particle field in the browser](tutorials/real-world/01-particle-field.md)
107-
- [WebGPU SDF in the browser](tutorials/real-world/02-sdf-canvas.md)
107+
- [Live edits over a stream](tutorials/real-world/02-live-edits.md)
108108
- [Building a Visual Editor](tutorials/building-visual-editor.md)
109109
- [ReactFlow Integration](tutorials/reactflow-reflow-integration.md)
110110
- [Performance Optimization](tutorials/performance-optimization.md)
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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

Comments
 (0)