Zoo

staging

← back to the zoo

10. Live for everyone

A change one person makes appears on everyone's open page within a second, without a reload.

Declare a channel in ratcloud.conf; browsers listen with EventSource at /rc/sub/<name>/<id>, the app POSTs to $RC_PUBLISH/pub/<name>/<id>.

This is the code serving the zoo right now: read from disk for this request, from commit 77ff74344d (staging). The zoo's own self-check fetches this page and compares it byte for byte with the file it runs.

exhibits/10-live-for-everyone.php sha256 eb18878b215f · raw

1<?php
2// Exhibit 10. A channel is declared in ratcloud.conf (`channels = counter, ...`). Browsers listen
3// at /rc/sub/counter/<id> with EventSource; the app publishes with a POST to
4// $RC_PUBLISH/pub/counter/<id>. That is the whole of realtime.
5return [
6    "n" => 10, "try" => "press the shared counter", "wing" => "Realtime",
7    "title" => "Live for everyone",
8    "promise" => "A change one person makes appears on everyone's open page within a second, without a reload.",
9    "block" => "Declare a channel in ratcloud.conf; browsers listen with EventSource at /rc/sub/<name>/<id>, the app POSTs to \$RC_PUBLISH/pub/<name>/<id>.",
10    "show" => function (?array $me): string {
11        return '<div class="row"><span class="big" id="counter">' . (int)meta("counter") . '</span><button class="primary" id="press">Press</button></div>'
12             . '<p class="muted out" id="counter-note">Open this page in two tabs and press in one.</p>';
13    },
14    "api" => function (string $do, ?array $me, array $in, bool $post): ?array {
15        if ($do !== "press" || !$post) return null;
16        db()->exec("BEGIN IMMEDIATE");
17        $n = (int)meta("counter") + 1;
18        meta_set("counter", (string)$n);
19        db()->exec("COMMIT");
20        $ok = publish("counter", "main", ["n" => $n, "at" => microtime(true)]);
21        return ["n" => $n, "published" => $ok];
22    },
23    // Two listeners, one press: both must see the new number within a second.
24    "check" => function (): array {
25        $host = "https://" . env("RC_HOST");
26        $sub = "$host/rc/sub/counter/main";
27        $b = new Browser();
28        $n = null;
29        [$status, $msgs, $ms] = $b->listen([$sub, $sub],
30            function () use ($b, $host, &$n) { $n = $b->post("$host/api/10/press")[2]["n"] ?? null; },
31            function ($m) use (&$n) { return $n !== null && count(array_filter($m, fn($l) => in_array($n, array_column(array_filter($l, "is_array"), "n"), true))) === 2; },
32            2000);
33        if ($n === null) return [false, "pressing the counter failed"];
34        if ($status !== [200, 200]) return [false, "the listeners were answered " . implode(", ", $status)];
35        $seen = count(array_filter($msgs, fn($l) => in_array($n, array_column(array_filter($l, "is_array"), "n"), true)));
36        if ($seen < 2) return [false, "pressed to $n; only $seen of 2 listeners saw it within 2 seconds"];
37        if ($ms > 1000) return [false, "both listeners saw $n, but after {$ms}ms"];
38        return [true, "pressed to $n; both listeners saw it {$ms}ms later (press included)"];
39    },
40    "script" => <<<'JS'
41(() => {
42  const el = document.getElementById("counter"), note = document.getElementById("counter-note");
43  if (!el) return;
44  const es = new EventSource("/rc/sub/counter/main");
45  es.onmessage = (m) => { const j = JSON.parse(m.data); el.textContent = j.n; };
46  es.onerror = () => { note.textContent = "Lost the live connection; retrying…"; };
47  es.onopen = () => { note.textContent = "Live. Open this page in another tab and press in one."; };
48  document.getElementById("press").addEventListener("click", () => zoo.call("/api/10/press", {}));
49})();
50
51JS,
52];