feat(web): stream live page fragments over SSE
GET /-/events follows EventService.Watch as the visitor of the request and sends the HTML fragments the page shows as Server-Sent Events: - topic=home: home-list, the row of a repository (new push, created, updated), or a data-gone row when it is deleted or no longer visible; - topic=repo:<name>&view=summary: repo-last-push; - view=refs: refs-list (the table and the facts of the rail); - view=commits&ref=<branch>&head=<sha>: commits-head, the "N new commits — reload" banner counted from the head the page lists; - gap: events were lost, or what the page shows is gone: the page reloads. Event ids are the Watch ids, Last-Event-ID is resume_after, an idle stream gets a comment every 25 s (Config.Heartbeat), each event is flushed and the write deadline is extended per write, so a server write timeout does not end the stream. A private or unknown repository is a 404, a refused watch (resource_exhausted) a 503 with Retry-After, a bad topic a 400. The pages set Layout.Live and LiveURL; the commits page gets the #commits-head slot on the first page of a branch tip only. ermiteLive in ermite.js swaps the fragments by id. The fragments live in fragments.templ, the handler in events.go.
- commit
- 2cfcc8579f0069987c36f474f9fef609e3151f27
- author
- brice · · 2026-10-10 13:38 UTC
- committer
- brice · · 2026-10-10 13:39 UTC
- parent
- fb91573
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
30 files · +2109 -513
M cmd/ermite/testdata/web/commits-page2.html +1 -1
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div class="days"> | |
| 65 | 65 | <section aria-label="2026-03-01"> | |
| 66 | 66 | <h2 class="rule-h"> | |
| 67 | 67 | <span>2026-03-01</span> | |
| @@ -61,7 +61,7 @@ | |||||
| 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | ||
| 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | ||
| 63 | </div> | 63 | </div> | ||
| 64 | - | <div class="days" id="commits-head"> | 64 | + | <div class="days"> |
| 65 | <section aria-label="2026-03-01"> | 65 | <section aria-label="2026-03-01"> | ||
| 66 | <h2 class="rule-h"> | 66 | <h2 class="rule-h"> | ||
| 67 | <span>2026-03-01</span> | 67 | <span>2026-03-01</span> | ||
M cmd/ermite/testdata/web/commits-path.html +1 -1
| @@ -65,7 +65,7 @@ | |||
| 65 | 65 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 66 | 66 | </div> | |
| 67 | 67 | <p class="lede dim">history of <code>a.txt</code> at main</p> | |
| 68 | - | <div class="days" id="commits-head"> | |
| 68 | + | <div class="days"> | |
| 69 | 69 | <section aria-label="2026-03-16"> | |
| 70 | 70 | <h2 class="rule-h"> | |
| 71 | 71 | <span>2026-03-16</span> | |
| @@ -65,7 +65,7 @@ | |||||
| 65 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | 65 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | ||
| 66 | </div> | 66 | </div> | ||
| 67 | <p class="lede dim">history of <code>a.txt</code> at main</p> | 67 | <p class="lede dim">history of <code>a.txt</code> at main</p> | ||
| 68 | - | <div class="days" id="commits-head"> | 68 | + | <div class="days"> |
| 69 | <section aria-label="2026-03-16"> | 69 | <section aria-label="2026-03-16"> | ||
| 70 | <h2 class="rule-h"> | 70 | <h2 class="rule-h"> | ||
| 71 | <span>2026-03-16</span> | 71 | <span>2026-03-16</span> | ||
M cmd/ermite/testdata/web/commits-root.html +7 -2
| @@ -61,7 +61,9 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div id="commits-head"> | |
| 65 | + | </div> | |
| 66 | + | <div class="days"> | |
| 65 | 67 | <section aria-label="2026-03-26"> | |
| 66 | 68 | <h2 class="rule-h"> | |
| 67 | 69 | <span>2026-03-26</span> | |
| @@ -1085,7 +1087,10 @@ | |||
| 1085 | 1087 | <span class="st-ver">ermite devel · </span> | |
| 1086 | 1088 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 1087 | 1089 | </span> | |
| 1088 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 1090 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 1091 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 1092 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 1093 | + | gap -> ermiteLive.reload()">offline</span> | |
| 1089 | 1094 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span> | |
| 1090 | 1095 | </footer> | |
| 1091 | 1096 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| @@ -61,7 +61,9 @@ | |||||
| 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | ||
| 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | ||
| 63 | </div> | 63 | </div> | ||
| 64 | - | <div class="days" id="commits-head"> | 64 | + | <div id="commits-head"> |
| 65 | + | </div> | |||
| 66 | + | <div class="days"> | |||
| 65 | <section aria-label="2026-03-26"> | 67 | <section aria-label="2026-03-26"> | ||
| 66 | <h2 class="rule-h"> | 68 | <h2 class="rule-h"> | ||
| 67 | <span>2026-03-26</span> | 69 | <span>2026-03-26</span> | ||
| @@ -1085,7 +1087,10 @@ | |||||
| 1085 | <span class="st-ver">ermite devel · </span> | 1087 | <span class="st-ver">ermite devel · </span> | ||
| 1086 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | 1088 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | ||
| 1087 | </span> | 1089 | </span> | ||
| 1088 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | 1090 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; |
| 1091 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 1092 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 1093 | + | gap -> ermiteLive.reload()">offline</span> | |||
| 1089 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span> | 1094 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span> | ||
| 1090 | </footer> | 1095 | </footer> | ||
| 1091 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | 1096 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | ||
M cmd/ermite/testdata/web/commits-small.html +7 -2
| @@ -61,7 +61,9 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div id="commits-head"> | |
| 65 | + | </div> | |
| 66 | + | <div class="days"> | |
| 65 | 67 | <section aria-label="2026-02-08"> | |
| 66 | 68 | <h2 class="rule-h"> | |
| 67 | 69 | <span>2026-02-08</span> | |
| @@ -445,7 +447,10 @@ | |||
| 445 | 447 | <span class="st-ver">ermite devel · </span> | |
| 446 | 448 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 447 | 449 | </span> | |
| 448 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 450 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 451 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 452 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 453 | + | gap -> ermiteLive.reload()">offline</span> | |
| 449 | 454 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span> | |
| 450 | 455 | </footer> | |
| 451 | 456 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| @@ -61,7 +61,9 @@ | |||||
| 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | ||
| 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | ||
| 63 | </div> | 63 | </div> | ||
| 64 | - | <div class="days" id="commits-head"> | 64 | + | <div id="commits-head"> |
| 65 | + | </div> | |||
| 66 | + | <div class="days"> | |||
| 65 | <section aria-label="2026-02-08"> | 67 | <section aria-label="2026-02-08"> | ||
| 66 | <h2 class="rule-h"> | 68 | <h2 class="rule-h"> | ||
| 67 | <span>2026-02-08</span> | 69 | <span>2026-02-08</span> | ||
| @@ -445,7 +447,10 @@ | |||||
| 445 | <span class="st-ver">ermite devel · </span> | 447 | <span class="st-ver">ermite devel · </span> | ||
| 446 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | 448 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | ||
| 447 | </span> | 449 | </span> | ||
| 448 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | 450 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; |
| 451 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 452 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 453 | + | gap -> ermiteLive.reload()">offline</span> | |||
| 449 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span> | 454 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span> | ||
| 450 | </footer> | 455 | </footer> | ||
| 451 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | 456 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | ||
M cmd/ermite/testdata/web/home.html +4 -1
| @@ -218,7 +218,10 @@ | |||
| 218 | 218 | <span class="st-ver">ermite devel · </span> | |
| 219 | 219 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 220 | 220 | </span> | |
| 221 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 221 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 222 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 223 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 224 | + | gap -> ermiteLive.reload()">offline</span> | |
| 222 | 225 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |
| 223 | 226 | </footer> | |
| 224 | 227 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| @@ -218,7 +218,10 @@ | |||||
| 218 | <span class="st-ver">ermite devel · </span> | 218 | <span class="st-ver">ermite devel · </span> | ||
| 219 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | 219 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | ||
| 220 | </span> | 220 | </span> | ||
| 221 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | 221 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; |
| 222 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 223 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 224 | + | gap -> ermiteLive.reload()">offline</span> | |||
| 222 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | 225 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | ||
| 223 | </footer> | 226 | </footer> | ||
| 224 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | 227 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | ||
M cmd/ermite/testdata/web/refs.html +5 -2
| @@ -139,7 +139,7 @@ | |||
| 139 | 139 | <summary>refs · legend</summary> | |
| 140 | 140 | <section aria-labelledby="r-refs"> | |
| 141 | 141 | <h2 id="r-refs">refs</h2> | |
| 142 | - | <dl class="kv"> | |
| 142 | + | <dl class="kv" id="refs-kv"> | |
| 143 | 143 | <dt>default</dt> | |
| 144 | 144 | <dd>main</dd> | |
| 145 | 145 | <dt>branches</dt> | |
| @@ -354,7 +354,10 @@ | |||
| 354 | 354 | <span class="st-ver">ermite devel · </span> | |
| 355 | 355 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 356 | 356 | </span> | |
| 357 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 357 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 358 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 359 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 360 | + | gap -> ermiteLive.reload()">offline</span> | |
| 358 | 361 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span> | |
| 359 | 362 | </footer> | |
| 360 | 363 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| @@ -139,7 +139,7 @@ | |||||
| 139 | <summary>refs · legend</summary> | 139 | <summary>refs · legend</summary> | ||
| 140 | <section aria-labelledby="r-refs"> | 140 | <section aria-labelledby="r-refs"> | ||
| 141 | <h2 id="r-refs">refs</h2> | 141 | <h2 id="r-refs">refs</h2> | ||
| 142 | - | <dl class="kv"> | 142 | + | <dl class="kv" id="refs-kv"> |
| 143 | <dt>default</dt> | 143 | <dt>default</dt> | ||
| 144 | <dd>main</dd> | 144 | <dd>main</dd> | ||
| 145 | <dt>branches</dt> | 145 | <dt>branches</dt> | ||
| @@ -354,7 +354,10 @@ | |||||
| 354 | <span class="st-ver">ermite devel · </span> | 354 | <span class="st-ver">ermite devel · </span> | ||
| 355 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | 355 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | ||
| 356 | </span> | 356 | </span> | ||
| 357 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | 357 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; |
| 358 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 359 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 360 | + | gap -> ermiteLive.reload()">offline</span> | |||
| 358 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span> | 361 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span> | ||
| 359 | </footer> | 362 | </footer> | ||
| 360 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | 363 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | ||
M cmd/ermite/testdata/web/summary.html +4 -1
| @@ -348,7 +348,10 @@ | |||
| 348 | 348 | <span class="st-ver">ermite devel · </span> | |
| 349 | 349 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 350 | 350 | </span> | |
| 351 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 351 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 352 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 353 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 354 | + | gap -> ermiteLive.reload()">offline</span> | |
| 352 | 355 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |
| 353 | 356 | </footer> | |
| 354 | 357 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| @@ -348,7 +348,10 @@ | |||||
| 348 | <span class="st-ver">ermite devel · </span> | 348 | <span class="st-ver">ermite devel · </span> | ||
| 349 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | 349 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | ||
| 350 | </span> | 350 | </span> | ||
| 351 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | 351 | + | <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live'; |
| 352 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 353 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 354 | + | gap -> ermiteLive.reload()">offline</span> | |||
| 352 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | 355 | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | ||
| 353 | </footer> | 356 | </footer> | ||
| 354 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | 357 | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | ||
M cmd/ermite/web.go +1 -0
| @@ -20,6 +20,7 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||
| 20 | 20 | srv, err := web.New(web.Config{ | |
| 21 | 21 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| 22 | 22 | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | |
| 23 | + | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | |
| 23 | 24 | PublicURL: cfg.PublicURL, | |
| 24 | 25 | SourceURL: cfg.SourceURL, | |
| 25 | 26 | Version: serverVersion(), | |
| @@ -20,6 +20,7 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||||
| 20 | srv, err := web.New(web.Config{ | 20 | srv, err := web.New(web.Config{ | ||
| 21 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | 21 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | ||
| 22 | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | 22 | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | ||
| 23 | + | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | |||
| 23 | PublicURL: cfg.PublicURL, | 24 | PublicURL: cfg.PublicURL, | ||
| 24 | SourceURL: cfg.SourceURL, | 25 | SourceURL: cfg.SourceURL, | ||
| 25 | Version: serverVersion(), | 26 | Version: serverVersion(), | ||
A cmd/ermite/web_events_test.go +388 -0
| @@ -0,0 +1,388 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "html" | |
| 9 | + | "net/http" | |
| 10 | + | "regexp" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/app" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // sseEvent is one event read from /-/events. | |
| 23 | + | type sseEvent struct { | |
| 24 | + | id, name, data string | |
| 25 | + | comment bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // sseStream is a client of /-/events. | |
| 29 | + | type sseStream struct { | |
| 30 | + | resp *http.Response | |
| 31 | + | events chan sseEvent | |
| 32 | + | cancel context.CancelFunc | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // sse opens /-/events?<query> like the browser does and reads it in the | |
| 36 | + | // background. It returns once the server answered, which it does when the | |
| 37 | + | // watch is registered: what happens after is not missed. | |
| 38 | + | func (h *harness) sse(query, lastID string) *sseStream { | |
| 39 | + | h.t.Helper() | |
| 40 | + | ctx, cancel := context.WithCancel(h.ctx) | |
| 41 | + | h.t.Cleanup(cancel) | |
| 42 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?"+query), nil) | |
| 43 | + | require.NoError(h.t, err) | |
| 44 | + | if lastID != "" { | |
| 45 | + | req.Header.Set("Last-Event-ID", lastID) | |
| 46 | + | } | |
| 47 | + | resp, err := http.DefaultClient.Do(req) | |
| 48 | + | require.NoError(h.t, err) | |
| 49 | + | t := h.t | |
| 50 | + | h.t.Cleanup(func() { _ = resp.Body.Close() }) | |
| 51 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 52 | + | s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel} | |
| 53 | + | go func() { | |
| 54 | + | defer close(s.events) | |
| 55 | + | sc := bufio.NewScanner(resp.Body) | |
| 56 | + | sc.Buffer(make([]byte, 0, 64<<10), 1<<20) | |
| 57 | + | var cur sseEvent | |
| 58 | + | var data []string | |
| 59 | + | for sc.Scan() { | |
| 60 | + | line := sc.Text() | |
| 61 | + | switch { | |
| 62 | + | case line == "": | |
| 63 | + | if cur.id != "" || cur.name != "" || len(data) > 0 { | |
| 64 | + | cur.data = strings.Join(data, "\n") | |
| 65 | + | s.events <- cur | |
| 66 | + | } | |
| 67 | + | cur, data = sseEvent{}, nil | |
| 68 | + | case strings.HasPrefix(line, ":"): | |
| 69 | + | s.events <- sseEvent{comment: true, data: strings.TrimSpace(line[1:])} | |
| 70 | + | default: | |
| 71 | + | k, v, _ := strings.Cut(line, ":") | |
| 72 | + | v = strings.TrimPrefix(v, " ") | |
| 73 | + | switch k { | |
| 74 | + | case "id": | |
| 75 | + | cur.id = v | |
| 76 | + | case "event": | |
| 77 | + | cur.name = v | |
| 78 | + | case "data": | |
| 79 | + | data = append(data, v) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | }() | |
| 84 | + | return s | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // next returns the next event (not a comment) or fails after d. | |
| 88 | + | func (s *sseStream) next(t *testing.T, d time.Duration) sseEvent { | |
| 89 | + | t.Helper() | |
| 90 | + | timeout := time.After(d) | |
| 91 | + | for { | |
| 92 | + | select { | |
| 93 | + | case ev, ok := <-s.events: | |
| 94 | + | if !ok { | |
| 95 | + | t.Fatal("the stream ended") | |
| 96 | + | } | |
| 97 | + | if ev.comment { | |
| 98 | + | continue | |
| 99 | + | } | |
| 100 | + | return ev | |
| 101 | + | case <-timeout: | |
| 102 | + | t.Fatalf("no event within %v", d) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // AC5: a push shows up as the row of the repository on the home stream | |
| 108 | + | // within a second; creating, hiding and deleting repositories updates or | |
| 109 | + | // removes the row. | |
| 110 | + | func TestWebEventsHome(t *testing.T) { | |
| 111 | + | h := newHarness(t, harnessOpts{}) | |
| 112 | + | h.seed("pub", domain.VisibilityPublic) | |
| 113 | + | s := h.sse("topic=home", "") | |
| 114 | + | assert.Equal(t, "text/event-stream", s.resp.Header.Get("Content-Type")) | |
| 115 | + | assert.Contains(t, s.resp.Header.Get("Cache-Control"), "no-cache") | |
| 116 | + | ||
| 117 | + | wt := h.workTree("main", "a.txt") | |
| 118 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 119 | + | start := time.Now() | |
| 120 | + | ev := s.next(t, time.Second) | |
| 121 | + | assert.Less(t, time.Since(start), 2*time.Second) | |
| 122 | + | assert.Equal(t, "home-list", ev.name) | |
| 123 | + | assert.NotEmpty(t, ev.id) | |
| 124 | + | assert.Contains(t, ev.data, `<tr id="repo-pub"`) | |
| 125 | + | assert.Contains(t, ev.data, `new push`) | |
| 126 | + | assert.Contains(t, ev.data, `href="/pub"`) | |
| 127 | + | ||
| 128 | + | // A new repository is a row; so is a repository created by a push. | |
| 129 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | ev = s.next(t, watchWait) | |
| 132 | + | assert.Equal(t, "home-list", ev.name) | |
| 133 | + | assert.Contains(t, ev.data, `<tr id="repo-fresh"`) | |
| 134 | + | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | |
| 135 | + | assert.NotContains(t, ev.data, "new push") | |
| 136 | + | ||
| 137 | + | // A repository that turns private is removed from the anonymous list. | |
| 138 | + | priv := domain.VisibilityPrivate | |
| 139 | + | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | ev = s.next(t, watchWait) | |
| 142 | + | assert.Equal(t, "home-list", ev.name) | |
| 143 | + | assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`) | |
| 144 | + | assert.NotContains(t, ev.data, "fresh one") | |
| 145 | + | ||
| 146 | + | // And a deleted one. | |
| 147 | + | require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub"))) | |
| 148 | + | ev = s.next(t, watchWait) | |
| 149 | + | assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // AC5: the stream of a repository carries its last push; the refs page its | |
| 153 | + | // refs; the commits page the banner with the number of new commits. | |
| 154 | + | func TestWebEventsRepoFragments(t *testing.T) { | |
| 155 | + | h := newHarness(t, harnessOpts{}) | |
| 156 | + | h.seed("x", domain.VisibilityPublic) | |
| 157 | + | wt := h.workTree("main", "a.txt") | |
| 158 | + | push := func() { h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main") } | |
| 159 | + | push() | |
| 160 | + | ||
| 161 | + | summary := h.sse("topic=repo:x&view=summary", "") | |
| 162 | + | refs := h.sse("topic=repo:x&view=refs", "") | |
| 163 | + | ||
| 164 | + | _, _, page := h.get("/x/commits/main") | |
| 165 | + | require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner") | |
| 166 | + | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | |
| 167 | + | require.Len(t, m, 2, "the commits page is live") | |
| 168 | + | url := html.UnescapeString(m[1]) | |
| 169 | + | require.True(t, strings.HasPrefix(url, "/-/events?"), url) | |
| 170 | + | commits := h.sse(strings.TrimPrefix(url, "/-/events?"), "") | |
| 171 | + | ||
| 172 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 173 | + | h.writeFile(wt, "c.txt", []byte("c\n")) | |
| 174 | + | start := time.Now() | |
| 175 | + | push() | |
| 176 | + | ||
| 177 | + | ev := summary.next(t, time.Second) | |
| 178 | + | assert.Less(t, time.Since(start), 2*time.Second) | |
| 179 | + | assert.Equal(t, "repo-last-push", ev.name) | |
| 180 | + | assert.Contains(t, ev.data, `<dd id="last-push-x">`) | |
| 181 | + | assert.Contains(t, ev.data, "now") | |
| 182 | + | ||
| 183 | + | ev = refs.next(t, watchWait) | |
| 184 | + | assert.Equal(t, "refs-list", ev.name) | |
| 185 | + | assert.Contains(t, ev.data, `<tbody id="ref-rows" data-rows>`) | |
| 186 | + | assert.Contains(t, ev.data, `>main</a>`) | |
| 187 | + | ev = refs.next(t, watchWait) | |
| 188 | + | assert.Equal(t, "refs-list", ev.name) | |
| 189 | + | assert.Contains(t, ev.data, `<dl class="kv" id="refs-kv">`) | |
| 190 | + | ||
| 191 | + | ev = commits.next(t, watchWait) | |
| 192 | + | assert.Equal(t, "commits-head", ev.name) | |
| 193 | + | assert.Contains(t, ev.data, `id="commits-head"`) | |
| 194 | + | assert.Contains(t, ev.data, "2 new commits — reload") | |
| 195 | + | assert.Contains(t, ev.data, `href="/x/commits/main"`) | |
| 196 | + | ||
| 197 | + | // One more commit: the count is since the head the page lists. | |
| 198 | + | h.writeFile(wt, "d.txt", []byte("d\n")) | |
| 199 | + | push() | |
| 200 | + | assert.Contains(t, commits.next(t, watchWait).data, "3 new commits — reload") | |
| 201 | + | ||
| 202 | + | // A push to another branch is not news for this page. | |
| 203 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main:refs/heads/side") | |
| 204 | + | idOnly := commits.next(t, watchWait) | |
| 205 | + | assert.NotEmpty(t, idOnly.id, "the event is acknowledged, so that a reconnection resumes after it") | |
| 206 | + | assert.Empty(t, idOnly.name) | |
| 207 | + | assert.Empty(t, idOnly.data) | |
| 208 | + | h.writeFile(wt, "e.txt", []byte("e\n")) | |
| 209 | + | push() | |
| 210 | + | assert.Contains(t, commits.next(t, watchWait).data, "4 new commits — reload") | |
| 211 | + | ||
| 212 | + | // Pages that do not list the head of a branch do not announce anything. | |
| 213 | + | _, _, detached := h.get("/x/commits/main/a.txt") | |
| 214 | + | assert.NotContains(t, detached, `id="commits-head"`) | |
| 215 | + | assert.NotContains(t, detached, "hx-sse:connect") | |
| 216 | + | _, _, old := h.get("/x/commits/main?page=nope") | |
| 217 | + | assert.NotContains(t, old, "hx-sse:connect") | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // AC5: Last-Event-ID resumes after that event; an id nobody issued is a gap, | |
| 221 | + | // which tells the page to load itself again. | |
| 222 | + | func TestWebEventsResumeAndGap(t *testing.T) { | |
| 223 | + | h := newHarness(t, harnessOpts{}) | |
| 224 | + | h.seed("pub", domain.VisibilityPublic) | |
| 225 | + | s := h.sse("topic=home", "") | |
| 226 | + | wt := h.workTree("main", "a.txt") | |
| 227 | + | var ids []string | |
| 228 | + | for i := range 3 { | |
| 229 | + | if i > 0 { | |
| 230 | + | h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n")) | |
| 231 | + | } | |
| 232 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 233 | + | ids = append(ids, s.next(t, watchWait).id) | |
| 234 | + | } | |
| 235 | + | s.cancel() | |
| 236 | + | ||
| 237 | + | r := h.sse("topic=home", ids[0]) | |
| 238 | + | for _, want := range ids[1:] { | |
| 239 | + | ev := r.next(t, watchWait) | |
| 240 | + | assert.Equal(t, want, ev.id) | |
| 241 | + | assert.Equal(t, "home-list", ev.name) | |
| 242 | + | } | |
| 243 | + | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 244 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 245 | + | live := r.next(t, watchWait) | |
| 246 | + | assert.NotContains(t, ids, live.id) | |
| 247 | + | ||
| 248 | + | g := h.sse("topic=home", "not-an-id") | |
| 249 | + | gap := g.next(t, watchWait) | |
| 250 | + | assert.Equal(t, "gap", gap.name) | |
| 251 | + | assert.Empty(t, gap.id) | |
| 252 | + | assert.Equal(t, "home-list", g.next(t, watchWait).name, "the oldest buffered event follows") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // A small ring: the id that fell out of it is a gap. | |
| 256 | + | func TestWebEventsGapAfterOverflow(t *testing.T) { | |
| 257 | + | h := newHarness(t, harnessOpts{eventBuffer: 3}) | |
| 258 | + | h.seed("pub", domain.VisibilityPublic) | |
| 259 | + | s := h.sse("topic=home", "") | |
| 260 | + | wt := h.workTree("main", "a.txt") | |
| 261 | + | var ids []string | |
| 262 | + | for i := range 6 { | |
| 263 | + | if i > 0 { | |
| 264 | + | h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n")) | |
| 265 | + | } | |
| 266 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 267 | + | ids = append(ids, s.next(t, watchWait).id) | |
| 268 | + | } | |
| 269 | + | r := h.sse("topic=home", ids[1]) | |
| 270 | + | assert.Equal(t, "gap", r.next(t, watchWait).name) | |
| 271 | + | assert.Equal(t, ids[3], r.next(t, watchWait).id) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // AC2, AC5: nothing of a private repository reaches the anonymous stream, | |
| 275 | + | // and its topic is a missing one. | |
| 276 | + | func TestWebEventsPrivate(t *testing.T) { | |
| 277 | + | h := newHarness(t, harnessOpts{}) | |
| 278 | + | h.seed("pub", domain.VisibilityPublic) | |
| 279 | + | h.seed("priv", domain.VisibilityPrivate) | |
| 280 | + | s := h.sse("topic=home", "") | |
| 281 | + | ||
| 282 | + | missing := h.status("GET", "/-/events?topic=repo:nothing", nil) | |
| 283 | + | assert.Equal(t, http.StatusNotFound, missing) | |
| 284 | + | assert.Equal(t, missing, h.status("GET", "/-/events?topic=repo:priv", nil), "private is indistinguishable from missing") | |
| 285 | + | ||
| 286 | + | wt := h.workTree("main", "a.txt") | |
| 287 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 288 | + | desc := "secret change" | |
| 289 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc}) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | // Made public later, it does not reveal what happened while it was | |
| 292 | + | // private; the update that makes it public is shown. | |
| 293 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 294 | + | ev := s.next(t, watchWait) | |
| 295 | + | assert.Contains(t, ev.data, `id="repo-pub"`, "the first event is the public push: %s", ev.data) | |
| 296 | + | h.sentinelSSE(t, s, "sentinel") | |
| 297 | + | _, _, body := h.get("/") | |
| 298 | + | assert.Contains(t, body, "repo-pub") | |
| 299 | + | assert.NotContains(t, body, "repo-priv") | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // sentinelSSE proves that nothing came since the last event: it creates a | |
| 303 | + | // public repository and requires its row to be the next event. | |
| 304 | + | func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) { | |
| 305 | + | t.Helper() | |
| 306 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 307 | + | require.NoError(t, err) | |
| 308 | + | ev := s.next(t, watchWait) | |
| 309 | + | assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // A topic is home or repo:<name>; anything else is refused before a stream | |
| 313 | + | // starts. | |
| 314 | + | func TestWebEventsTopics(t *testing.T) { | |
| 315 | + | h := newHarness(t, harnessOpts{}) | |
| 316 | + | h.seed("pub", domain.VisibilityPublic) | |
| 317 | + | for path, want := range map[string]int{ | |
| 318 | + | "/-/events": http.StatusBadRequest, | |
| 319 | + | "/-/events?topic=": http.StatusBadRequest, | |
| 320 | + | "/-/events?topic=other": http.StatusBadRequest, | |
| 321 | + | "/-/events?topic=repo:": http.StatusBadRequest, | |
| 322 | + | "/-/events?topic=repo:Bad%20Name": http.StatusNotFound, | |
| 323 | + | "/-/events?topic=repo:..": http.StatusNotFound, | |
| 324 | + | "/-/events?topic=repo:nothing": http.StatusNotFound, | |
| 325 | + | "/-/events?topic=repo:pub.git": http.StatusNotFound, | |
| 326 | + | "/-/events?topic=home,repo:pub": http.StatusBadRequest, | |
| 327 | + | "/-/events?topic=repo:pub&view=zzz": http.StatusOK, | |
| 328 | + | } { | |
| 329 | + | if want == http.StatusOK { | |
| 330 | + | s := h.sse(strings.TrimPrefix(path, "/-/events?"), "") | |
| 331 | + | s.cancel() | |
| 332 | + | continue | |
| 333 | + | } | |
| 334 | + | assert.Equal(t, want, h.status("GET", path, nil), path) | |
| 335 | + | } | |
| 336 | + | // POST is not allowed. | |
| 337 | + | assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil)) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // The pages connect to the stream of their topic. | |
| 341 | + | func TestWebPagesAreLive(t *testing.T) { | |
| 342 | + | h := newHarness(t, harnessOpts{}) | |
| 343 | + | h.webRepo("demo") | |
| 344 | + | for path, want := range map[string]string{ | |
| 345 | + | "/": `hx-sse:connect="/-/events?topic=home"`, | |
| 346 | + | "/demo": `hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary"`, | |
| 347 | + | "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs"`, | |
| 348 | + | } { | |
| 349 | + | _, _, body := h.get(path) | |
| 350 | + | assert.Contains(t, body, want, path) | |
| 351 | + | } | |
| 352 | + | _, _, nf := h.get("/nothing") | |
| 353 | + | assert.NotContains(t, nf, "hx-sse:connect") | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // The stream ends cleanly when the server stops its event log (the start of | |
| 357 | + | // the shutdown): the client sees the end of the body, and the handler is gone | |
| 358 | + | // (the test server cannot close with a handler left). | |
| 359 | + | func TestWebEventsEndOnShutdown(t *testing.T) { | |
| 360 | + | h := newHarness(t, harnessOpts{}) | |
| 361 | + | h.seed("pub", domain.VisibilityPublic) | |
| 362 | + | home := h.sse("topic=home", "") | |
| 363 | + | repo := h.sse("topic=repo:pub", "") | |
| 364 | + | ||
| 365 | + | h.forge.EventLog.Close(context.WithoutCancel(h.ctx)) | |
| 366 | + | for _, s := range []*sseStream{home, repo} { | |
| 367 | + | select { | |
| 368 | + | case _, ok := <-s.events: | |
| 369 | + | for ok { | |
| 370 | + | _, ok = <-s.events | |
| 371 | + | } | |
| 372 | + | case <-time.After(5 * time.Second): | |
| 373 | + | t.Fatal("the stream did not end") | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // A visitor that goes away frees its watch. | |
| 380 | + | func TestWebEventsClientDisconnect(t *testing.T) { | |
| 381 | + | h := newHarness(t, harnessOpts{}) | |
| 382 | + | h.seed("pub", domain.VisibilityPublic) | |
| 383 | + | require.Equal(t, 0, h.forge.EventLog.Watchers()) | |
| 384 | + | s := h.sse("topic=home", "") | |
| 385 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, 5*time.Millisecond) | |
| 386 | + | s.cancel() | |
| 387 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond) | |
| 388 | + | } | |
| @@ -0,0 +1,388 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "context" | |||
| 8 | + | "html" | |||
| 9 | + | "net/http" | |||
| 10 | + | "regexp" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/app" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // sseEvent is one event read from /-/events. | |||
| 23 | + | type sseEvent struct { | |||
| 24 | + | id, name, data string | |||
| 25 | + | comment bool | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // sseStream is a client of /-/events. | |||
| 29 | + | type sseStream struct { | |||
| 30 | + | resp *http.Response | |||
| 31 | + | events chan sseEvent | |||
| 32 | + | cancel context.CancelFunc | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // sse opens /-/events?<query> like the browser does and reads it in the | |||
| 36 | + | // background. It returns once the server answered, which it does when the | |||
| 37 | + | // watch is registered: what happens after is not missed. | |||
| 38 | + | func (h *harness) sse(query, lastID string) *sseStream { | |||
| 39 | + | h.t.Helper() | |||
| 40 | + | ctx, cancel := context.WithCancel(h.ctx) | |||
| 41 | + | h.t.Cleanup(cancel) | |||
| 42 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?"+query), nil) | |||
| 43 | + | require.NoError(h.t, err) | |||
| 44 | + | if lastID != "" { | |||
| 45 | + | req.Header.Set("Last-Event-ID", lastID) | |||
| 46 | + | } | |||
| 47 | + | resp, err := http.DefaultClient.Do(req) | |||
| 48 | + | require.NoError(h.t, err) | |||
| 49 | + | t := h.t | |||
| 50 | + | h.t.Cleanup(func() { _ = resp.Body.Close() }) | |||
| 51 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 52 | + | s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel} | |||
| 53 | + | go func() { | |||
| 54 | + | defer close(s.events) | |||
| 55 | + | sc := bufio.NewScanner(resp.Body) | |||
| 56 | + | sc.Buffer(make([]byte, 0, 64<<10), 1<<20) | |||
| 57 | + | var cur sseEvent | |||
| 58 | + | var data []string | |||
| 59 | + | for sc.Scan() { | |||
| 60 | + | line := sc.Text() | |||
| 61 | + | switch { | |||
| 62 | + | case line == "": | |||
| 63 | + | if cur.id != "" || cur.name != "" || len(data) > 0 { | |||
| 64 | + | cur.data = strings.Join(data, "\n") | |||
| 65 | + | s.events <- cur | |||
| 66 | + | } | |||
| 67 | + | cur, data = sseEvent{}, nil | |||
| 68 | + | case strings.HasPrefix(line, ":"): | |||
| 69 | + | s.events <- sseEvent{comment: true, data: strings.TrimSpace(line[1:])} | |||
| 70 | + | default: | |||
| 71 | + | k, v, _ := strings.Cut(line, ":") | |||
| 72 | + | v = strings.TrimPrefix(v, " ") | |||
| 73 | + | switch k { | |||
| 74 | + | case "id": | |||
| 75 | + | cur.id = v | |||
| 76 | + | case "event": | |||
| 77 | + | cur.name = v | |||
| 78 | + | case "data": | |||
| 79 | + | data = append(data, v) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | }() | |||
| 84 | + | return s | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // next returns the next event (not a comment) or fails after d. | |||
| 88 | + | func (s *sseStream) next(t *testing.T, d time.Duration) sseEvent { | |||
| 89 | + | t.Helper() | |||
| 90 | + | timeout := time.After(d) | |||
| 91 | + | for { | |||
| 92 | + | select { | |||
| 93 | + | case ev, ok := <-s.events: | |||
| 94 | + | if !ok { | |||
| 95 | + | t.Fatal("the stream ended") | |||
| 96 | + | } | |||
| 97 | + | if ev.comment { | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 100 | + | return ev | |||
| 101 | + | case <-timeout: | |||
| 102 | + | t.Fatalf("no event within %v", d) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // AC5: a push shows up as the row of the repository on the home stream | |||
| 108 | + | // within a second; creating, hiding and deleting repositories updates or | |||
| 109 | + | // removes the row. | |||
| 110 | + | func TestWebEventsHome(t *testing.T) { | |||
| 111 | + | h := newHarness(t, harnessOpts{}) | |||
| 112 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 113 | + | s := h.sse("topic=home", "") | |||
| 114 | + | assert.Equal(t, "text/event-stream", s.resp.Header.Get("Content-Type")) | |||
| 115 | + | assert.Contains(t, s.resp.Header.Get("Cache-Control"), "no-cache") | |||
| 116 | + | ||||
| 117 | + | wt := h.workTree("main", "a.txt") | |||
| 118 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 119 | + | start := time.Now() | |||
| 120 | + | ev := s.next(t, time.Second) | |||
| 121 | + | assert.Less(t, time.Since(start), 2*time.Second) | |||
| 122 | + | assert.Equal(t, "home-list", ev.name) | |||
| 123 | + | assert.NotEmpty(t, ev.id) | |||
| 124 | + | assert.Contains(t, ev.data, `<tr id="repo-pub"`) | |||
| 125 | + | assert.Contains(t, ev.data, `new push`) | |||
| 126 | + | assert.Contains(t, ev.data, `href="/pub"`) | |||
| 127 | + | ||||
| 128 | + | // A new repository is a row; so is a repository created by a push. | |||
| 129 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | ev = s.next(t, watchWait) | |||
| 132 | + | assert.Equal(t, "home-list", ev.name) | |||
| 133 | + | assert.Contains(t, ev.data, `<tr id="repo-fresh"`) | |||
| 134 | + | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | |||
| 135 | + | assert.NotContains(t, ev.data, "new push") | |||
| 136 | + | ||||
| 137 | + | // A repository that turns private is removed from the anonymous list. | |||
| 138 | + | priv := domain.VisibilityPrivate | |||
| 139 | + | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv}) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | ev = s.next(t, watchWait) | |||
| 142 | + | assert.Equal(t, "home-list", ev.name) | |||
| 143 | + | assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`) | |||
| 144 | + | assert.NotContains(t, ev.data, "fresh one") | |||
| 145 | + | ||||
| 146 | + | // And a deleted one. | |||
| 147 | + | require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub"))) | |||
| 148 | + | ev = s.next(t, watchWait) | |||
| 149 | + | assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // AC5: the stream of a repository carries its last push; the refs page its | |||
| 153 | + | // refs; the commits page the banner with the number of new commits. | |||
| 154 | + | func TestWebEventsRepoFragments(t *testing.T) { | |||
| 155 | + | h := newHarness(t, harnessOpts{}) | |||
| 156 | + | h.seed("x", domain.VisibilityPublic) | |||
| 157 | + | wt := h.workTree("main", "a.txt") | |||
| 158 | + | push := func() { h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main") } | |||
| 159 | + | push() | |||
| 160 | + | ||||
| 161 | + | summary := h.sse("topic=repo:x&view=summary", "") | |||
| 162 | + | refs := h.sse("topic=repo:x&view=refs", "") | |||
| 163 | + | ||||
| 164 | + | _, _, page := h.get("/x/commits/main") | |||
| 165 | + | require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner") | |||
| 166 | + | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | |||
| 167 | + | require.Len(t, m, 2, "the commits page is live") | |||
| 168 | + | url := html.UnescapeString(m[1]) | |||
| 169 | + | require.True(t, strings.HasPrefix(url, "/-/events?"), url) | |||
| 170 | + | commits := h.sse(strings.TrimPrefix(url, "/-/events?"), "") | |||
| 171 | + | ||||
| 172 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |||
| 173 | + | h.writeFile(wt, "c.txt", []byte("c\n")) | |||
| 174 | + | start := time.Now() | |||
| 175 | + | push() | |||
| 176 | + | ||||
| 177 | + | ev := summary.next(t, time.Second) | |||
| 178 | + | assert.Less(t, time.Since(start), 2*time.Second) | |||
| 179 | + | assert.Equal(t, "repo-last-push", ev.name) | |||
| 180 | + | assert.Contains(t, ev.data, `<dd id="last-push-x">`) | |||
| 181 | + | assert.Contains(t, ev.data, "now") | |||
| 182 | + | ||||
| 183 | + | ev = refs.next(t, watchWait) | |||
| 184 | + | assert.Equal(t, "refs-list", ev.name) | |||
| 185 | + | assert.Contains(t, ev.data, `<tbody id="ref-rows" data-rows>`) | |||
| 186 | + | assert.Contains(t, ev.data, `>main</a>`) | |||
| 187 | + | ev = refs.next(t, watchWait) | |||
| 188 | + | assert.Equal(t, "refs-list", ev.name) | |||
| 189 | + | assert.Contains(t, ev.data, `<dl class="kv" id="refs-kv">`) | |||
| 190 | + | ||||
| 191 | + | ev = commits.next(t, watchWait) | |||
| 192 | + | assert.Equal(t, "commits-head", ev.name) | |||
| 193 | + | assert.Contains(t, ev.data, `id="commits-head"`) | |||
| 194 | + | assert.Contains(t, ev.data, "2 new commits — reload") | |||
| 195 | + | assert.Contains(t, ev.data, `href="/x/commits/main"`) | |||
| 196 | + | ||||
| 197 | + | // One more commit: the count is since the head the page lists. | |||
| 198 | + | h.writeFile(wt, "d.txt", []byte("d\n")) | |||
| 199 | + | push() | |||
| 200 | + | assert.Contains(t, commits.next(t, watchWait).data, "3 new commits — reload") | |||
| 201 | + | ||||
| 202 | + | // A push to another branch is not news for this page. | |||
| 203 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main:refs/heads/side") | |||
| 204 | + | idOnly := commits.next(t, watchWait) | |||
| 205 | + | assert.NotEmpty(t, idOnly.id, "the event is acknowledged, so that a reconnection resumes after it") | |||
| 206 | + | assert.Empty(t, idOnly.name) | |||
| 207 | + | assert.Empty(t, idOnly.data) | |||
| 208 | + | h.writeFile(wt, "e.txt", []byte("e\n")) | |||
| 209 | + | push() | |||
| 210 | + | assert.Contains(t, commits.next(t, watchWait).data, "4 new commits — reload") | |||
| 211 | + | ||||
| 212 | + | // Pages that do not list the head of a branch do not announce anything. | |||
| 213 | + | _, _, detached := h.get("/x/commits/main/a.txt") | |||
| 214 | + | assert.NotContains(t, detached, `id="commits-head"`) | |||
| 215 | + | assert.NotContains(t, detached, "hx-sse:connect") | |||
| 216 | + | _, _, old := h.get("/x/commits/main?page=nope") | |||
| 217 | + | assert.NotContains(t, old, "hx-sse:connect") | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // AC5: Last-Event-ID resumes after that event; an id nobody issued is a gap, | |||
| 221 | + | // which tells the page to load itself again. | |||
| 222 | + | func TestWebEventsResumeAndGap(t *testing.T) { | |||
| 223 | + | h := newHarness(t, harnessOpts{}) | |||
| 224 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 225 | + | s := h.sse("topic=home", "") | |||
| 226 | + | wt := h.workTree("main", "a.txt") | |||
| 227 | + | var ids []string | |||
| 228 | + | for i := range 3 { | |||
| 229 | + | if i > 0 { | |||
| 230 | + | h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n")) | |||
| 231 | + | } | |||
| 232 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 233 | + | ids = append(ids, s.next(t, watchWait).id) | |||
| 234 | + | } | |||
| 235 | + | s.cancel() | |||
| 236 | + | ||||
| 237 | + | r := h.sse("topic=home", ids[0]) | |||
| 238 | + | for _, want := range ids[1:] { | |||
| 239 | + | ev := r.next(t, watchWait) | |||
| 240 | + | assert.Equal(t, want, ev.id) | |||
| 241 | + | assert.Equal(t, "home-list", ev.name) | |||
| 242 | + | } | |||
| 243 | + | h.writeFile(wt, "live.txt", []byte("x\n")) | |||
| 244 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 245 | + | live := r.next(t, watchWait) | |||
| 246 | + | assert.NotContains(t, ids, live.id) | |||
| 247 | + | ||||
| 248 | + | g := h.sse("topic=home", "not-an-id") | |||
| 249 | + | gap := g.next(t, watchWait) | |||
| 250 | + | assert.Equal(t, "gap", gap.name) | |||
| 251 | + | assert.Empty(t, gap.id) | |||
| 252 | + | assert.Equal(t, "home-list", g.next(t, watchWait).name, "the oldest buffered event follows") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // A small ring: the id that fell out of it is a gap. | |||
| 256 | + | func TestWebEventsGapAfterOverflow(t *testing.T) { | |||
| 257 | + | h := newHarness(t, harnessOpts{eventBuffer: 3}) | |||
| 258 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 259 | + | s := h.sse("topic=home", "") | |||
| 260 | + | wt := h.workTree("main", "a.txt") | |||
| 261 | + | var ids []string | |||
| 262 | + | for i := range 6 { | |||
| 263 | + | if i > 0 { | |||
| 264 | + | h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n")) | |||
| 265 | + | } | |||
| 266 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 267 | + | ids = append(ids, s.next(t, watchWait).id) | |||
| 268 | + | } | |||
| 269 | + | r := h.sse("topic=home", ids[1]) | |||
| 270 | + | assert.Equal(t, "gap", r.next(t, watchWait).name) | |||
| 271 | + | assert.Equal(t, ids[3], r.next(t, watchWait).id) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // AC2, AC5: nothing of a private repository reaches the anonymous stream, | |||
| 275 | + | // and its topic is a missing one. | |||
| 276 | + | func TestWebEventsPrivate(t *testing.T) { | |||
| 277 | + | h := newHarness(t, harnessOpts{}) | |||
| 278 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 279 | + | h.seed("priv", domain.VisibilityPrivate) | |||
| 280 | + | s := h.sse("topic=home", "") | |||
| 281 | + | ||||
| 282 | + | missing := h.status("GET", "/-/events?topic=repo:nothing", nil) | |||
| 283 | + | assert.Equal(t, http.StatusNotFound, missing) | |||
| 284 | + | assert.Equal(t, missing, h.status("GET", "/-/events?topic=repo:priv", nil), "private is indistinguishable from missing") | |||
| 285 | + | ||||
| 286 | + | wt := h.workTree("main", "a.txt") | |||
| 287 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |||
| 288 | + | desc := "secret change" | |||
| 289 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc}) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | // Made public later, it does not reveal what happened while it was | |||
| 292 | + | // private; the update that makes it public is shown. | |||
| 293 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |||
| 294 | + | ev := s.next(t, watchWait) | |||
| 295 | + | assert.Contains(t, ev.data, `id="repo-pub"`, "the first event is the public push: %s", ev.data) | |||
| 296 | + | h.sentinelSSE(t, s, "sentinel") | |||
| 297 | + | _, _, body := h.get("/") | |||
| 298 | + | assert.Contains(t, body, "repo-pub") | |||
| 299 | + | assert.NotContains(t, body, "repo-priv") | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // sentinelSSE proves that nothing came since the last event: it creates a | |||
| 303 | + | // public repository and requires its row to be the next event. | |||
| 304 | + | func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) { | |||
| 305 | + | t.Helper() | |||
| 306 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |||
| 307 | + | require.NoError(t, err) | |||
| 308 | + | ev := s.next(t, watchWait) | |||
| 309 | + | assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // A topic is home or repo:<name>; anything else is refused before a stream | |||
| 313 | + | // starts. | |||
| 314 | + | func TestWebEventsTopics(t *testing.T) { | |||
| 315 | + | h := newHarness(t, harnessOpts{}) | |||
| 316 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 317 | + | for path, want := range map[string]int{ | |||
| 318 | + | "/-/events": http.StatusBadRequest, | |||
| 319 | + | "/-/events?topic=": http.StatusBadRequest, | |||
| 320 | + | "/-/events?topic=other": http.StatusBadRequest, | |||
| 321 | + | "/-/events?topic=repo:": http.StatusBadRequest, | |||
| 322 | + | "/-/events?topic=repo:Bad%20Name": http.StatusNotFound, | |||
| 323 | + | "/-/events?topic=repo:..": http.StatusNotFound, | |||
| 324 | + | "/-/events?topic=repo:nothing": http.StatusNotFound, | |||
| 325 | + | "/-/events?topic=repo:pub.git": http.StatusNotFound, | |||
| 326 | + | "/-/events?topic=home,repo:pub": http.StatusBadRequest, | |||
| 327 | + | "/-/events?topic=repo:pub&view=zzz": http.StatusOK, | |||
| 328 | + | } { | |||
| 329 | + | if want == http.StatusOK { | |||
| 330 | + | s := h.sse(strings.TrimPrefix(path, "/-/events?"), "") | |||
| 331 | + | s.cancel() | |||
| 332 | + | continue | |||
| 333 | + | } | |||
| 334 | + | assert.Equal(t, want, h.status("GET", path, nil), path) | |||
| 335 | + | } | |||
| 336 | + | // POST is not allowed. | |||
| 337 | + | assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil)) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // The pages connect to the stream of their topic. | |||
| 341 | + | func TestWebPagesAreLive(t *testing.T) { | |||
| 342 | + | h := newHarness(t, harnessOpts{}) | |||
| 343 | + | h.webRepo("demo") | |||
| 344 | + | for path, want := range map[string]string{ | |||
| 345 | + | "/": `hx-sse:connect="/-/events?topic=home"`, | |||
| 346 | + | "/demo": `hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary"`, | |||
| 347 | + | "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs"`, | |||
| 348 | + | } { | |||
| 349 | + | _, _, body := h.get(path) | |||
| 350 | + | assert.Contains(t, body, want, path) | |||
| 351 | + | } | |||
| 352 | + | _, _, nf := h.get("/nothing") | |||
| 353 | + | assert.NotContains(t, nf, "hx-sse:connect") | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // The stream ends cleanly when the server stops its event log (the start of | |||
| 357 | + | // the shutdown): the client sees the end of the body, and the handler is gone | |||
| 358 | + | // (the test server cannot close with a handler left). | |||
| 359 | + | func TestWebEventsEndOnShutdown(t *testing.T) { | |||
| 360 | + | h := newHarness(t, harnessOpts{}) | |||
| 361 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 362 | + | home := h.sse("topic=home", "") | |||
| 363 | + | repo := h.sse("topic=repo:pub", "") | |||
| 364 | + | ||||
| 365 | + | h.forge.EventLog.Close(context.WithoutCancel(h.ctx)) | |||
| 366 | + | for _, s := range []*sseStream{home, repo} { | |||
| 367 | + | select { | |||
| 368 | + | case _, ok := <-s.events: | |||
| 369 | + | for ok { | |||
| 370 | + | _, ok = <-s.events | |||
| 371 | + | } | |||
| 372 | + | case <-time.After(5 * time.Second): | |||
| 373 | + | t.Fatal("the stream did not end") | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // A visitor that goes away frees its watch. | |||
| 380 | + | func TestWebEventsClientDisconnect(t *testing.T) { | |||
| 381 | + | h := newHarness(t, harnessOpts{}) | |||
| 382 | + | h.seed("pub", domain.VisibilityPublic) | |||
| 383 | + | require.Equal(t, 0, h.forge.EventLog.Watchers()) | |||
| 384 | + | s := h.sse("topic=home", "") | |||
| 385 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, 5*time.Millisecond) | |||
| 386 | + | s.cancel() | |||
| 387 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond) | |||
| 388 | + | } | |||
M internal/adapters/web/commits.go +7 -0
| @@ -40,6 +40,8 @@ type CommitsPage struct { | |||
| 40 | 40 | Open *Link | |
| 41 | 41 | // Refs are the branch heads and tags among the commits of this page. | |
| 42 | 42 | Refs []Link | |
| 43 | + | // Live is true when the page announces new commits of its branch. | |
| 44 | + | Live bool | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | // revSel is what the URL of a history page selects. | |
| @@ -126,6 +128,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 126 | 128 | ||
| 127 | 129 | l := s.repoLayout(ctx, d, "commits") | |
| 128 | 130 | s.setRef(&l, sel) | |
| 131 | + | if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 { | |
| 132 | + | // The first page of a branch announces the commits pushed to it. | |
| 133 | + | p.Live = true | |
| 134 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev) | |
| 135 | + | } | |
| 129 | 136 | l.Crumbs = commitsCrumbs(d, sel) | |
| 130 | 137 | if sel.path == "" { | |
| 131 | 138 | l.Title = "commits · " + d.name() + " · " + s.instance | |
| @@ -40,6 +40,8 @@ type CommitsPage struct { | |||||
| 40 | Open *Link | 40 | Open *Link | ||
| 41 | // Refs are the branch heads and tags among the commits of this page. | 41 | // Refs are the branch heads and tags among the commits of this page. | ||
| 42 | Refs []Link | 42 | Refs []Link | ||
| 43 | + | // Live is true when the page announces new commits of its branch. | |||
| 44 | + | Live bool | |||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| 45 | // revSel is what the URL of a history page selects. | 47 | // revSel is what the URL of a history page selects. | ||
| @@ -126,6 +128,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 126 | 128 | ||||
| 127 | l := s.repoLayout(ctx, d, "commits") | 129 | l := s.repoLayout(ctx, d, "commits") | ||
| 128 | s.setRef(&l, sel) | 130 | s.setRef(&l, sel) | ||
| 131 | + | if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 { | |||
| 132 | + | // The first page of a branch announces the commits pushed to it. | |||
| 133 | + | p.Live = true | |||
| 134 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev) | |||
| 135 | + | } | |||
| 129 | l.Crumbs = commitsCrumbs(d, sel) | 136 | l.Crumbs = commitsCrumbs(d, sel) | ||
| 130 | if sel.path == "" { | 137 | if sel.path == "" { | ||
| 131 | l.Title = "commits · " + d.name() + " · " + s.instance | 138 | l.Title = "commits · " + d.name() + " · " + s.instance | ||
M internal/adapters/web/commits.templ +4 -1
| @@ -22,7 +22,10 @@ templ Commits(l Layout, p CommitsPage) { | |||
| 22 | 22 | if p.Path != "" { | |
| 23 | 23 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | |
| 24 | 24 | } | |
| 25 | - | <div class="days" id="commits-head"> | |
| 25 | + | if p.Live { | |
| 26 | + | <div id="commits-head"></div> | |
| 27 | + | } | |
| 28 | + | <div class="days"> | |
| 26 | 29 | for _, g := range p.Days { | |
| 27 | 30 | <section aria-label={ g.Day }> | |
| 28 | 31 | <h2 class="rule-h"><span>{ g.Day }</span></h2> | |
| @@ -22,7 +22,10 @@ templ Commits(l Layout, p CommitsPage) { | |||||
| 22 | if p.Path != "" { | 22 | if p.Path != "" { | ||
| 23 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | 23 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | ||
| 24 | } | 24 | } | ||
| 25 | - | <div class="days" id="commits-head"> | 25 | + | if p.Live { |
| 26 | + | <div id="commits-head"></div> | |||
| 27 | + | } | |||
| 28 | + | <div class="days"> | |||
| 26 | for _, g := range p.Days { | 29 | for _, g := range p.Days { | ||
| 27 | <section aria-label={ g.Day }> | 30 | <section aria-label={ g.Day }> | ||
| 28 | <h2 class="rule-h"><span>{ g.Day }</span></h2> | 31 | <h2 class="rule-h"><span>{ g.Day }</span></h2> | ||
M internal/adapters/web/commits_templ.go +53 -43 generated file: collapsed
M internal/adapters/web/components.templ +7 -12
| @@ -7,17 +7,7 @@ templ Facts(facts []Fact) { | |||
| 7 | 7 | <dl class="kv"> | |
| 8 | 8 | for _, f := range facts { | |
| 9 | 9 | <dt>{ f.Key }</dt> | |
| 10 | - | <dd | |
| 11 | - | if f.ID != "" { | |
| 12 | - | id={ f.ID } | |
| 13 | - | } | |
| 14 | - | > | |
| 15 | - | if f.Time != "" { | |
| 16 | - | <time datetime={ f.Time }>{ f.Val }</time> | |
| 17 | - | } else { | |
| 18 | - | { f.Val } | |
| 19 | - | } | |
| 20 | - | </dd> | |
| 10 | + | @FactValue(f) | |
| 21 | 11 | } | |
| 22 | 12 | </dl> | |
| 23 | 13 | } | |
| @@ -45,7 +35,12 @@ templ CloneBlock(c Clone) { | |||
| 45 | 35 | templ HomeRowView(r HomeRow) { | |
| 46 | 36 | <tr id={ r.ID } data-cursor?={ r.Cursor }> | |
| 47 | 37 | <td class="cur" aria-hidden="true"></td> | |
| 48 | - | <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td> | |
| 38 | + | <td class="c-name"> | |
| 39 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 40 | + | if r.Fresh { | |
| 41 | + | <span class="tagged">● new push</span> | |
| 42 | + | } | |
| 43 | + | </td> | |
| 49 | 44 | <td class="c-desc dim">{ r.Desc }</td> | |
| 50 | 45 | <td class="c-when nowrap"> | |
| 51 | 46 | <time | |
| @@ -7,17 +7,7 @@ templ Facts(facts []Fact) { | |||||
| 7 | <dl class="kv"> | 7 | <dl class="kv"> | ||
| 8 | for _, f := range facts { | 8 | for _, f := range facts { | ||
| 9 | <dt>{ f.Key }</dt> | 9 | <dt>{ f.Key }</dt> | ||
| 10 | - | <dd | 10 | + | @FactValue(f) |
| 11 | - | if f.ID != "" { | |||
| 12 | - | id={ f.ID } | |||
| 13 | - | } | |||
| 14 | - | > | |||
| 15 | - | if f.Time != "" { | |||
| 16 | - | <time datetime={ f.Time }>{ f.Val }</time> | |||
| 17 | - | } else { | |||
| 18 | - | { f.Val } | |||
| 19 | - | } | |||
| 20 | - | </dd> | |||
| 21 | } | 11 | } | ||
| 22 | </dl> | 12 | </dl> | ||
| 23 | } | 13 | } | ||
| @@ -45,7 +35,12 @@ templ CloneBlock(c Clone) { | |||||
| 45 | templ HomeRowView(r HomeRow) { | 35 | templ HomeRowView(r HomeRow) { | ||
| 46 | <tr id={ r.ID } data-cursor?={ r.Cursor }> | 36 | <tr id={ r.ID } data-cursor?={ r.Cursor }> | ||
| 47 | <td class="cur" aria-hidden="true"></td> | 37 | <td class="cur" aria-hidden="true"></td> | ||
| 48 | - | <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td> | 38 | + | <td class="c-name"> |
| 39 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |||
| 40 | + | if r.Fresh { | |||
| 41 | + | <span class="tagged">● new push</span> | |||
| 42 | + | } | |||
| 43 | + | </td> | |||
| 49 | <td class="c-desc dim">{ r.Desc }</td> | 44 | <td class="c-desc dim">{ r.Desc }</td> | ||
| 50 | <td class="c-when nowrap"> | 45 | <td class="c-when nowrap"> | ||
| 51 | <time | 46 | <time | ||
M internal/adapters/web/components_templ.go +178 -233 generated file: collapsed
A internal/adapters/web/events.go +334 -0
| @@ -0,0 +1,334 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "net/url" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "connectrpc.com/connect" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Live updates. A page that can change under the eyes of its reader opens | |
| 22 | + | // GET /-/events (the Live component of the status bar). The handler follows | |
| 23 | + | // EventService.Watch as the visitor of the request and turns each event into | |
| 24 | + | // the HTML fragments the page shows, sent as Server-Sent Events: | |
| 25 | + | // | |
| 26 | + | // GET /-/events?topic=home | |
| 27 | + | // GET /-/events?topic=repo:<name>&view=summary|refs | |
| 28 | + | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 29 | + | // | |
| 30 | + | // topic is the Watch topic. view says which fragments of a repository the | |
| 31 | + | // page shows, so that the server renders only those (summary when absent). | |
| 32 | + | // On the commits page, ref is the branch it lists and head the commit it | |
| 33 | + | // lists first: the banner counts the commits between that head and the new | |
| 34 | + | // tip, so it stays right however many pushes came in and however often the | |
| 35 | + | // stream reconnected. A Last-Event-ID header resumes the stream | |
| 36 | + | // (resume_after). | |
| 37 | + | // | |
| 38 | + | // Events, each with the id of the Watch event it comes from: | |
| 39 | + | // | |
| 40 | + | // home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr> | |
| 41 | + | // for a repository that is deleted or no longer visible | |
| 42 | + | // repo-last-push data: <dd id="last-push-<name>">…</dd> | |
| 43 | + | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 44 | + | // <dl id="refs-kv">…</dl>, the facts of the rail | |
| 45 | + | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 46 | + | // gap data: gap; events were lost, or what the page shows is gone or | |
| 47 | + | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 48 | + | // | |
| 49 | + | // The data of an event is one fragment, a top-level element with an id, one | |
| 50 | + | // "data:" line per line of HTML; the page swaps the element that has that id. | |
| 51 | + | // An event that changes nothing on the page is still sent as an id alone, so | |
| 52 | + | // that a reconnection resumes after it. | |
| 53 | + | ||
| 54 | + | // DefaultHeartbeat is how often an idle stream gets a comment line, so that | |
| 55 | + | // proxies keep it open and a dead peer is noticed. | |
| 56 | + | const DefaultHeartbeat = 25 * time.Second | |
| 57 | + | ||
| 58 | + | const ( | |
| 59 | + | // startGrace is how long the handler waits after the Watch call answered | |
| 60 | + | // for an early failure (too many watchers) before it commits to 200: the | |
| 61 | + | // Connect protocol reports it in the stream, after the headers. | |
| 62 | + | startGrace = 100 * time.Millisecond | |
| 63 | + | // writeWindow bounds one write to the visitor, whatever the write timeout | |
| 64 | + | // of the server is. | |
| 65 | + | writeWindow = 30 * time.Second | |
| 66 | + | // retryAfter is the delay asked from a visitor refused for lack of room. | |
| 67 | + | retryAfter = 10 | |
| 68 | + | ) | |
| 69 | + | ||
| 70 | + | // parseTopic reads "home" or "repo:<name>"; repo is "" for home. | |
| 71 | + | func parseTopic(topic string) (repo string, ok bool) { | |
| 72 | + | if topic == "home" { | |
| 73 | + | return "", true | |
| 74 | + | } | |
| 75 | + | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | |
| 76 | + | return name, true | |
| 77 | + | } | |
| 78 | + | return "", false | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // events is GET /-/events. | |
| 82 | + | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |
| 83 | + | if s.cfg.Events == nil { | |
| 84 | + | http.NotFound(w, r) | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | q := r.URL.Query() | |
| 88 | + | topic := q.Get("topic") | |
| 89 | + | repo, ok := parseTopic(topic) | |
| 90 | + | if !ok { | |
| 91 | + | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ctx, cancel := context.WithCancel(r.Context()) | |
| 95 | + | defer cancel() | |
| 96 | + | log := zerowrap.FromCtx(ctx) | |
| 97 | + | ||
| 98 | + | if repo != "" { | |
| 99 | + | // A repository the visitor cannot see is a missing one (AC2). | |
| 100 | + | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | |
| 101 | + | _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo})) | |
| 102 | + | ccancel() | |
| 103 | + | if err != nil { | |
| 104 | + | s.denyEvents(ctx, w, err) | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{ | |
| 110 | + | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | |
| 111 | + | })) | |
| 112 | + | if err != nil { | |
| 113 | + | s.denyEvents(ctx, w, err) | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | // Wait for the answer of the API, which comes once the watch is registered | |
| 117 | + | // (or refused), then read the stream from a goroutine. | |
| 118 | + | _ = stream.ResponseHeader() | |
| 119 | + | msgs := make(chan recvd) | |
| 120 | + | done := make(chan struct{}) | |
| 121 | + | go func() { | |
| 122 | + | defer close(done) | |
| 123 | + | defer close(msgs) | |
| 124 | + | for stream.Receive() { | |
| 125 | + | select { | |
| 126 | + | case msgs <- recvd{ev: stream.Msg().GetEvent()}: | |
| 127 | + | case <-ctx.Done(): | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if err := stream.Err(); err != nil { | |
| 132 | + | select { | |
| 133 | + | case msgs <- recvd{err: err}: | |
| 134 | + | case <-ctx.Done(): | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | }() | |
| 138 | + | defer func() { | |
| 139 | + | cancel() | |
| 140 | + | _ = stream.Close() | |
| 141 | + | <-done | |
| 142 | + | }() | |
| 143 | + | ||
| 144 | + | var first *recvd | |
| 145 | + | grace := time.NewTimer(startGrace) | |
| 146 | + | select { | |
| 147 | + | case m, open := <-msgs: | |
| 148 | + | grace.Stop() | |
| 149 | + | if !open { | |
| 150 | + | return // the server is stopping | |
| 151 | + | } | |
| 152 | + | first = &m | |
| 153 | + | case <-grace.C: | |
| 154 | + | case <-ctx.Done(): | |
| 155 | + | grace.Stop() | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | if first != nil && first.err != nil { | |
| 159 | + | s.denyEvents(ctx, w, first.err) | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | rc := http.NewResponseController(w) | |
| 164 | + | hdr := w.Header() | |
| 165 | + | hdr.Set("Content-Type", "text/event-stream") | |
| 166 | + | hdr.Set("Cache-Control", "no-cache, no-transform") | |
| 167 | + | hdr.Set("X-Accel-Buffering", "no") | |
| 168 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 169 | + | w.WriteHeader(http.StatusOK) | |
| 170 | + | out := &sseWriter{w: w, rc: rc} | |
| 171 | + | if err := out.raw(": connected\n\n"); err != nil { | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head")} | |
| 176 | + | handle := func(m recvd) bool { | |
| 177 | + | if m.err != nil { | |
| 178 | + | if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled { | |
| 179 | + | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | |
| 180 | + | } | |
| 181 | + | return false | |
| 182 | + | } | |
| 183 | + | rctx, rcancel := context.WithTimeout(ctx, requestTimeout) | |
| 184 | + | frags, err := st.render(rctx, m.ev) | |
| 185 | + | rcancel() | |
| 186 | + | if err != nil { | |
| 187 | + | if ctx.Err() != nil { | |
| 188 | + | return false | |
| 189 | + | } | |
| 190 | + | // What the page shows can no longer be told from the events: | |
| 191 | + | // have it fetch itself again. | |
| 192 | + | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | |
| 193 | + | frags = gap | |
| 194 | + | } | |
| 195 | + | return out.send(m.ev.GetId(), frags) == nil | |
| 196 | + | } | |
| 197 | + | if first != nil && !handle(*first) { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | beat := s.cfg.Heartbeat | |
| 202 | + | if beat <= 0 { | |
| 203 | + | beat = DefaultHeartbeat | |
| 204 | + | } | |
| 205 | + | tick := time.NewTicker(beat) | |
| 206 | + | defer tick.Stop() | |
| 207 | + | for { | |
| 208 | + | select { | |
| 209 | + | case m, open := <-msgs: | |
| 210 | + | if !open || !handle(m) { | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | case <-tick.C: | |
| 214 | + | if out.raw(": heartbeat\n\n") != nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | case <-ctx.Done(): | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // recvd is a message of the Watch stream, or how it failed. | |
| 224 | + | type recvd struct { | |
| 225 | + | ev *pb.Event | |
| 226 | + | err error | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // denyStream answers a request that gets no stream. | |
| 230 | + | func denyStream(w http.ResponseWriter, status int, msg string) { | |
| 231 | + | w.Header().Set("Cache-Control", "no-store") | |
| 232 | + | http.Error(w, msg, status) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // denyEvents maps an error of the API, before the stream started, to a | |
| 236 | + | // status. Not found and invalid are 404 whatever the reason: a private | |
| 237 | + | // repository is a missing one. | |
| 238 | + | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | |
| 239 | + | switch code := connect.CodeOf(err); { | |
| 240 | + | case code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |
| 241 | + | denyStream(w, http.StatusNotFound, "not found") | |
| 242 | + | case code == connect.CodeResourceExhausted: | |
| 243 | + | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | |
| 244 | + | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | |
| 245 | + | case code == connect.CodeUnauthenticated: | |
| 246 | + | denyStream(w, http.StatusUnauthorized, "unauthorized") | |
| 247 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 248 | + | // The visitor is gone. | |
| 249 | + | default: | |
| 250 | + | log := zerowrap.FromCtx(ctx) | |
| 251 | + | log.Error().Err(err).Msg("open event stream") | |
| 252 | + | denyStream(w, http.StatusInternalServerError, "internal error") | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // fragment is one SSE event to send: the name and the HTML of the fragment. | |
| 257 | + | type fragment struct{ event, html string } | |
| 258 | + | ||
| 259 | + | // sseWriter writes events to a visitor, flushing each, under a deadline of | |
| 260 | + | // its own so that the write timeout of the server cannot end the stream. | |
| 261 | + | type sseWriter struct { | |
| 262 | + | w http.ResponseWriter | |
| 263 | + | rc *http.ResponseController | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (o *sseWriter) raw(s string) error { | |
| 267 | + | _ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 268 | + | if _, err := io.WriteString(o.w, s); err != nil { | |
| 269 | + | return err | |
| 270 | + | } | |
| 271 | + | return o.rc.Flush() | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // send writes the fragments of one Watch event under its id. An event that | |
| 275 | + | // has no fragment still carries its id, so that a reconnection resumes after | |
| 276 | + | // it. | |
| 277 | + | func (o *sseWriter) send(id string, frags []fragment) error { | |
| 278 | + | if len(frags) == 0 { | |
| 279 | + | if id == "" { | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | return o.raw(formatEvent(id, "", "", false)) | |
| 283 | + | } | |
| 284 | + | for _, f := range frags { | |
| 285 | + | if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil { | |
| 286 | + | return err | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // formatEvent is one SSE event. Each line of data is a "data:" field, and a | |
| 293 | + | // browser joins them back with newlines. | |
| 294 | + | func formatEvent(id, event, data string, hasData bool) string { | |
| 295 | + | var b strings.Builder | |
| 296 | + | if event != "" { | |
| 297 | + | b.WriteString("event: " + event + "\n") | |
| 298 | + | } | |
| 299 | + | if id != "" { | |
| 300 | + | b.WriteString("id: " + id + "\n") | |
| 301 | + | } | |
| 302 | + | if hasData { | |
| 303 | + | data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n") | |
| 304 | + | for line := range strings.SplitSeq(data, "\n") { | |
| 305 | + | b.WriteString("data: " + line + "\n") | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | b.WriteString("\n") | |
| 309 | + | return b.String() | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // liveURL is the stream a page follows: the topic and, for a repository | |
| 313 | + | // page, what it shows (see the comment on events). | |
| 314 | + | func liveURL(topic, query string) string { | |
| 315 | + | u := "/-/events?topic=" + url.QueryEscape(topic) | |
| 316 | + | if query != "" { | |
| 317 | + | u += "&" + query | |
| 318 | + | } | |
| 319 | + | return u | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // isBranchHead reports whether sel is a branch at its tip, as opposed to a | |
| 323 | + | // tag or a commit id. | |
| 324 | + | func (d *repoData) isBranchHead(sel revSel) bool { | |
| 325 | + | if sel.detached || !isFullID(sel.rev) { | |
| 326 | + | return false | |
| 327 | + | } | |
| 328 | + | for _, b := range d.branches { | |
| 329 | + | if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev { | |
| 330 | + | return true | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | return false | |
| 334 | + | } | |
| @@ -0,0 +1,334 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/url" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "connectrpc.com/connect" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | ||||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Live updates. A page that can change under the eyes of its reader opens | |||
| 22 | + | // GET /-/events (the Live component of the status bar). The handler follows | |||
| 23 | + | // EventService.Watch as the visitor of the request and turns each event into | |||
| 24 | + | // the HTML fragments the page shows, sent as Server-Sent Events: | |||
| 25 | + | // | |||
| 26 | + | // GET /-/events?topic=home | |||
| 27 | + | // GET /-/events?topic=repo:<name>&view=summary|refs | |||
| 28 | + | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |||
| 29 | + | // | |||
| 30 | + | // topic is the Watch topic. view says which fragments of a repository the | |||
| 31 | + | // page shows, so that the server renders only those (summary when absent). | |||
| 32 | + | // On the commits page, ref is the branch it lists and head the commit it | |||
| 33 | + | // lists first: the banner counts the commits between that head and the new | |||
| 34 | + | // tip, so it stays right however many pushes came in and however often the | |||
| 35 | + | // stream reconnected. A Last-Event-ID header resumes the stream | |||
| 36 | + | // (resume_after). | |||
| 37 | + | // | |||
| 38 | + | // Events, each with the id of the Watch event it comes from: | |||
| 39 | + | // | |||
| 40 | + | // home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr> | |||
| 41 | + | // for a repository that is deleted or no longer visible | |||
| 42 | + | // repo-last-push data: <dd id="last-push-<name>">…</dd> | |||
| 43 | + | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |||
| 44 | + | // <dl id="refs-kv">…</dl>, the facts of the rail | |||
| 45 | + | // commits-head data: <div id="commits-head">N new commits — reload</div> | |||
| 46 | + | // gap data: gap; events were lost, or what the page shows is gone or | |||
| 47 | + | // changed: the page fetches itself again (ermiteLive in ermite.js) | |||
| 48 | + | // | |||
| 49 | + | // The data of an event is one fragment, a top-level element with an id, one | |||
| 50 | + | // "data:" line per line of HTML; the page swaps the element that has that id. | |||
| 51 | + | // An event that changes nothing on the page is still sent as an id alone, so | |||
| 52 | + | // that a reconnection resumes after it. | |||
| 53 | + | ||||
| 54 | + | // DefaultHeartbeat is how often an idle stream gets a comment line, so that | |||
| 55 | + | // proxies keep it open and a dead peer is noticed. | |||
| 56 | + | const DefaultHeartbeat = 25 * time.Second | |||
| 57 | + | ||||
| 58 | + | const ( | |||
| 59 | + | // startGrace is how long the handler waits after the Watch call answered | |||
| 60 | + | // for an early failure (too many watchers) before it commits to 200: the | |||
| 61 | + | // Connect protocol reports it in the stream, after the headers. | |||
| 62 | + | startGrace = 100 * time.Millisecond | |||
| 63 | + | // writeWindow bounds one write to the visitor, whatever the write timeout | |||
| 64 | + | // of the server is. | |||
| 65 | + | writeWindow = 30 * time.Second | |||
| 66 | + | // retryAfter is the delay asked from a visitor refused for lack of room. | |||
| 67 | + | retryAfter = 10 | |||
| 68 | + | ) | |||
| 69 | + | ||||
| 70 | + | // parseTopic reads "home" or "repo:<name>"; repo is "" for home. | |||
| 71 | + | func parseTopic(topic string) (repo string, ok bool) { | |||
| 72 | + | if topic == "home" { | |||
| 73 | + | return "", true | |||
| 74 | + | } | |||
| 75 | + | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | |||
| 76 | + | return name, true | |||
| 77 | + | } | |||
| 78 | + | return "", false | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // events is GET /-/events. | |||
| 82 | + | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 83 | + | if s.cfg.Events == nil { | |||
| 84 | + | http.NotFound(w, r) | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | q := r.URL.Query() | |||
| 88 | + | topic := q.Get("topic") | |||
| 89 | + | repo, ok := parseTopic(topic) | |||
| 90 | + | if !ok { | |||
| 91 | + | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | ctx, cancel := context.WithCancel(r.Context()) | |||
| 95 | + | defer cancel() | |||
| 96 | + | log := zerowrap.FromCtx(ctx) | |||
| 97 | + | ||||
| 98 | + | if repo != "" { | |||
| 99 | + | // A repository the visitor cannot see is a missing one (AC2). | |||
| 100 | + | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | |||
| 101 | + | _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo})) | |||
| 102 | + | ccancel() | |||
| 103 | + | if err != nil { | |||
| 104 | + | s.denyEvents(ctx, w, err) | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{ | |||
| 110 | + | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | |||
| 111 | + | })) | |||
| 112 | + | if err != nil { | |||
| 113 | + | s.denyEvents(ctx, w, err) | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | // Wait for the answer of the API, which comes once the watch is registered | |||
| 117 | + | // (or refused), then read the stream from a goroutine. | |||
| 118 | + | _ = stream.ResponseHeader() | |||
| 119 | + | msgs := make(chan recvd) | |||
| 120 | + | done := make(chan struct{}) | |||
| 121 | + | go func() { | |||
| 122 | + | defer close(done) | |||
| 123 | + | defer close(msgs) | |||
| 124 | + | for stream.Receive() { | |||
| 125 | + | select { | |||
| 126 | + | case msgs <- recvd{ev: stream.Msg().GetEvent()}: | |||
| 127 | + | case <-ctx.Done(): | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | if err := stream.Err(); err != nil { | |||
| 132 | + | select { | |||
| 133 | + | case msgs <- recvd{err: err}: | |||
| 134 | + | case <-ctx.Done(): | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | }() | |||
| 138 | + | defer func() { | |||
| 139 | + | cancel() | |||
| 140 | + | _ = stream.Close() | |||
| 141 | + | <-done | |||
| 142 | + | }() | |||
| 143 | + | ||||
| 144 | + | var first *recvd | |||
| 145 | + | grace := time.NewTimer(startGrace) | |||
| 146 | + | select { | |||
| 147 | + | case m, open := <-msgs: | |||
| 148 | + | grace.Stop() | |||
| 149 | + | if !open { | |||
| 150 | + | return // the server is stopping | |||
| 151 | + | } | |||
| 152 | + | first = &m | |||
| 153 | + | case <-grace.C: | |||
| 154 | + | case <-ctx.Done(): | |||
| 155 | + | grace.Stop() | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | if first != nil && first.err != nil { | |||
| 159 | + | s.denyEvents(ctx, w, first.err) | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | rc := http.NewResponseController(w) | |||
| 164 | + | hdr := w.Header() | |||
| 165 | + | hdr.Set("Content-Type", "text/event-stream") | |||
| 166 | + | hdr.Set("Cache-Control", "no-cache, no-transform") | |||
| 167 | + | hdr.Set("X-Accel-Buffering", "no") | |||
| 168 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |||
| 169 | + | w.WriteHeader(http.StatusOK) | |||
| 170 | + | out := &sseWriter{w: w, rc: rc} | |||
| 171 | + | if err := out.raw(": connected\n\n"); err != nil { | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head")} | |||
| 176 | + | handle := func(m recvd) bool { | |||
| 177 | + | if m.err != nil { | |||
| 178 | + | if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled { | |||
| 179 | + | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | |||
| 180 | + | } | |||
| 181 | + | return false | |||
| 182 | + | } | |||
| 183 | + | rctx, rcancel := context.WithTimeout(ctx, requestTimeout) | |||
| 184 | + | frags, err := st.render(rctx, m.ev) | |||
| 185 | + | rcancel() | |||
| 186 | + | if err != nil { | |||
| 187 | + | if ctx.Err() != nil { | |||
| 188 | + | return false | |||
| 189 | + | } | |||
| 190 | + | // What the page shows can no longer be told from the events: | |||
| 191 | + | // have it fetch itself again. | |||
| 192 | + | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | |||
| 193 | + | frags = gap | |||
| 194 | + | } | |||
| 195 | + | return out.send(m.ev.GetId(), frags) == nil | |||
| 196 | + | } | |||
| 197 | + | if first != nil && !handle(*first) { | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | beat := s.cfg.Heartbeat | |||
| 202 | + | if beat <= 0 { | |||
| 203 | + | beat = DefaultHeartbeat | |||
| 204 | + | } | |||
| 205 | + | tick := time.NewTicker(beat) | |||
| 206 | + | defer tick.Stop() | |||
| 207 | + | for { | |||
| 208 | + | select { | |||
| 209 | + | case m, open := <-msgs: | |||
| 210 | + | if !open || !handle(m) { | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | case <-tick.C: | |||
| 214 | + | if out.raw(": heartbeat\n\n") != nil { | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | case <-ctx.Done(): | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // recvd is a message of the Watch stream, or how it failed. | |||
| 224 | + | type recvd struct { | |||
| 225 | + | ev *pb.Event | |||
| 226 | + | err error | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // denyStream answers a request that gets no stream. | |||
| 230 | + | func denyStream(w http.ResponseWriter, status int, msg string) { | |||
| 231 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 232 | + | http.Error(w, msg, status) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // denyEvents maps an error of the API, before the stream started, to a | |||
| 236 | + | // status. Not found and invalid are 404 whatever the reason: a private | |||
| 237 | + | // repository is a missing one. | |||
| 238 | + | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | |||
| 239 | + | switch code := connect.CodeOf(err); { | |||
| 240 | + | case code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |||
| 241 | + | denyStream(w, http.StatusNotFound, "not found") | |||
| 242 | + | case code == connect.CodeResourceExhausted: | |||
| 243 | + | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | |||
| 244 | + | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | |||
| 245 | + | case code == connect.CodeUnauthenticated: | |||
| 246 | + | denyStream(w, http.StatusUnauthorized, "unauthorized") | |||
| 247 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |||
| 248 | + | // The visitor is gone. | |||
| 249 | + | default: | |||
| 250 | + | log := zerowrap.FromCtx(ctx) | |||
| 251 | + | log.Error().Err(err).Msg("open event stream") | |||
| 252 | + | denyStream(w, http.StatusInternalServerError, "internal error") | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // fragment is one SSE event to send: the name and the HTML of the fragment. | |||
| 257 | + | type fragment struct{ event, html string } | |||
| 258 | + | ||||
| 259 | + | // sseWriter writes events to a visitor, flushing each, under a deadline of | |||
| 260 | + | // its own so that the write timeout of the server cannot end the stream. | |||
| 261 | + | type sseWriter struct { | |||
| 262 | + | w http.ResponseWriter | |||
| 263 | + | rc *http.ResponseController | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (o *sseWriter) raw(s string) error { | |||
| 267 | + | _ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |||
| 268 | + | if _, err := io.WriteString(o.w, s); err != nil { | |||
| 269 | + | return err | |||
| 270 | + | } | |||
| 271 | + | return o.rc.Flush() | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // send writes the fragments of one Watch event under its id. An event that | |||
| 275 | + | // has no fragment still carries its id, so that a reconnection resumes after | |||
| 276 | + | // it. | |||
| 277 | + | func (o *sseWriter) send(id string, frags []fragment) error { | |||
| 278 | + | if len(frags) == 0 { | |||
| 279 | + | if id == "" { | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | return o.raw(formatEvent(id, "", "", false)) | |||
| 283 | + | } | |||
| 284 | + | for _, f := range frags { | |||
| 285 | + | if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil { | |||
| 286 | + | return err | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // formatEvent is one SSE event. Each line of data is a "data:" field, and a | |||
| 293 | + | // browser joins them back with newlines. | |||
| 294 | + | func formatEvent(id, event, data string, hasData bool) string { | |||
| 295 | + | var b strings.Builder | |||
| 296 | + | if event != "" { | |||
| 297 | + | b.WriteString("event: " + event + "\n") | |||
| 298 | + | } | |||
| 299 | + | if id != "" { | |||
| 300 | + | b.WriteString("id: " + id + "\n") | |||
| 301 | + | } | |||
| 302 | + | if hasData { | |||
| 303 | + | data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n") | |||
| 304 | + | for line := range strings.SplitSeq(data, "\n") { | |||
| 305 | + | b.WriteString("data: " + line + "\n") | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | b.WriteString("\n") | |||
| 309 | + | return b.String() | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // liveURL is the stream a page follows: the topic and, for a repository | |||
| 313 | + | // page, what it shows (see the comment on events). | |||
| 314 | + | func liveURL(topic, query string) string { | |||
| 315 | + | u := "/-/events?topic=" + url.QueryEscape(topic) | |||
| 316 | + | if query != "" { | |||
| 317 | + | u += "&" + query | |||
| 318 | + | } | |||
| 319 | + | return u | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // isBranchHead reports whether sel is a branch at its tip, as opposed to a | |||
| 323 | + | // tag or a commit id. | |||
| 324 | + | func (d *repoData) isBranchHead(sel revSel) bool { | |||
| 325 | + | if sel.detached || !isFullID(sel.rev) { | |||
| 326 | + | return false | |||
| 327 | + | } | |||
| 328 | + | for _, b := range d.branches { | |||
| 329 | + | if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev { | |||
| 330 | + | return true | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | return false | |||
| 334 | + | } | |||
A internal/adapters/web/events_test.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "strings" | |
| 12 | + | "sync/atomic" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "connectrpc.com/connect" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func TestFormatEvent(t *testing.T) { | |
| 25 | + | assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n", | |
| 26 | + | formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line") | |
| 27 | + | assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) | |
| 28 | + | assert.Equal(t, "id: b-2\n\n", formatEvent("b-2", "", "", false), "an id alone") | |
| 29 | + | assert.Equal(t, "event: e\ndata: a\ndata: b\ndata: c\n\n", formatEvent("", "e", "a\r\nb\rc", true), "no line break escapes the field") | |
| 30 | + | assert.Equal(t, "event: e\ndata: \n\n", formatEvent("", "e", "", true)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestParseTopic(t *testing.T) { | |
| 34 | + | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | |
| 35 | + | repo, ok := parseTopic(in) | |
| 36 | + | assert.True(t, ok, in) | |
| 37 | + | assert.Equal(t, want, repo, in) | |
| 38 | + | } | |
| 39 | + | for _, in := range []string{"", "repo:", "other", "home,repo:x", "Repo:x", "repo"} { | |
| 40 | + | _, ok := parseTopic(in) | |
| 41 | + | assert.False(t, ok, in) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestLiveURL(t *testing.T) { | |
| 46 | + | assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) | |
| 47 | + | assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestNewCommitsText(t *testing.T) { | |
| 51 | + | assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) | |
| 52 | + | assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) | |
| 53 | + | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // fakeEvents is an EventService whose behaviour a test sets. | |
| 57 | + | type fakeEvents struct { | |
| 58 | + | ermiteconnect.UnimplementedEventServiceHandler | |
| 59 | + | err error | |
| 60 | + | events []*pb.Event | |
| 61 | + | opened atomic.Int32 | |
| 62 | + | closed atomic.Int32 | |
| 63 | + | resume atomic.Value | |
| 64 | + | release chan struct{} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { | |
| 68 | + | f.opened.Add(1) | |
| 69 | + | defer f.closed.Add(1) | |
| 70 | + | f.resume.Store(req.Msg.GetResumeAfter()) | |
| 71 | + | if f.err != nil { | |
| 72 | + | return f.err | |
| 73 | + | } | |
| 74 | + | _ = st.Send(nil) | |
| 75 | + | for _, ev := range f.events { | |
| 76 | + | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | select { | |
| 81 | + | case <-ctx.Done(): | |
| 82 | + | case <-f.release: | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // repoOK answers every GetRepo with a public repository. | |
| 88 | + | type repoOK struct { | |
| 89 | + | ermiteconnect.UnimplementedRepoServiceHandler | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |
| 93 | + | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // eventsServer serves the web on a fake API, with a write timeout shorter | |
| 97 | + | // than the heartbeat, like a server that sets one. | |
| 98 | + | func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |
| 99 | + | t.Helper() | |
| 100 | + | api := http.NewServeMux() | |
| 101 | + | api.Handle(ermiteconnect.NewEventServiceHandler(f)) | |
| 102 | + | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | |
| 103 | + | backend := httptest.NewServer(api) | |
| 104 | + | t.Cleanup(backend.Close) | |
| 105 | + | ||
| 106 | + | srv, err := New(Config{ | |
| 107 | + | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |
| 108 | + | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |
| 109 | + | Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), | |
| 110 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 111 | + | Heartbeat: beat, | |
| 112 | + | }) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | ts := httptest.NewUnstartedServer(srv) | |
| 115 | + | ts.Config.WriteTimeout = writeTimeout | |
| 116 | + | ts.Start() | |
| 117 | + | t.Cleanup(ts.Close) | |
| 118 | + | return ts | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string) (*http.Response, context.CancelFunc) { | |
| 122 | + | t.Helper() | |
| 123 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 124 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/-/events?"+query, nil) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | for k, v := range hdr { | |
| 127 | + | req.Header.Set(k, v) | |
| 128 | + | } | |
| 129 | + | resp, err := ts.Client().Do(req) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | t.Cleanup(func() { cancel(); _ = resp.Body.Close() }) | |
| 132 | + | return resp, cancel | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // An idle stream gets a comment every heartbeat, and the write timeout of the | |
| 136 | + | // server does not end it. | |
| 137 | + | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |
| 138 | + | f := &fakeEvents{release: make(chan struct{})} | |
| 139 | + | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | |
| 140 | + | resp, cancel := open(t, ts, "topic=home", nil) | |
| 141 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 142 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |
| 143 | + | ||
| 144 | + | sc := bufio.NewScanner(resp.Body) | |
| 145 | + | beats := 0 | |
| 146 | + | deadline := time.AfterFunc(5*time.Second, cancel) | |
| 147 | + | defer deadline.Stop() | |
| 148 | + | start := time.Now() | |
| 149 | + | for sc.Scan() && beats < 8 { | |
| 150 | + | if strings.HasPrefix(sc.Text(), ": heartbeat") { | |
| 151 | + | beats++ | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | assert.Equal(t, 8, beats) | |
| 155 | + | assert.Greater(t, time.Since(start), 250*time.Millisecond, "the stream outlived the 100 ms write timeout") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // The id of the event, its name and the lines of its data come through; the | |
| 159 | + | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | |
| 160 | + | func TestEventsStreamsEventsAndResumes(t *testing.T) { | |
| 161 | + | f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ | |
| 162 | + | {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, | |
| 163 | + | {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, | |
| 164 | + | }} | |
| 165 | + | ts := eventsServer(t, f, time.Hour, 0) | |
| 166 | + | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | |
| 167 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 168 | + | sc := bufio.NewScanner(resp.Body) | |
| 169 | + | var lines []string | |
| 170 | + | for sc.Scan() { | |
| 171 | + | lines = append(lines, sc.Text()) | |
| 172 | + | if sc.Text() == "data: gap" { | |
| 173 | + | break | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | |
| 177 | + | assert.Contains(t, lines, "event: gap") | |
| 178 | + | assert.Equal(t, "b-6", f.resume.Load()) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Too many watchers is a 503 the browser can retry, not a stream. | |
| 182 | + | func TestEventsResourceExhausted(t *testing.T) { | |
| 183 | + | f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} | |
| 184 | + | ts := eventsServer(t, f, time.Hour, 0) | |
| 185 | + | resp, _ := open(t, ts, "topic=home", nil) | |
| 186 | + | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |
| 187 | + | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | |
| 188 | + | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |
| 189 | + | ||
| 190 | + | f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) | |
| 191 | + | resp, _ = open(t, ts, "topic=home", nil) | |
| 192 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 193 | + | f.err = errors.New("boom") | |
| 194 | + | resp, _ = open(t, ts, "topic=home", nil) | |
| 195 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // The handler returns, and the watch it opened ends, when the visitor goes | |
| 199 | + | // away or the API ends the stream. | |
| 200 | + | func TestEventsNoLeak(t *testing.T) { | |
| 201 | + | f := &fakeEvents{release: make(chan struct{})} | |
| 202 | + | ts := eventsServer(t, f, time.Hour, 0) | |
| 203 | + | ||
| 204 | + | resp, cancel := open(t, ts, "topic=home", nil) | |
| 205 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 206 | + | require.Eventually(t, func() bool { return f.opened.Load() == 1 }, 5*time.Second, time.Millisecond) | |
| 207 | + | cancel() | |
| 208 | + | require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the visitor left") | |
| 209 | + | ||
| 210 | + | resp, _ = open(t, ts, "topic=home", nil) | |
| 211 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 212 | + | require.Eventually(t, func() bool { return f.opened.Load() == 2 }, 5*time.Second, time.Millisecond) | |
| 213 | + | close(f.release) // the API ends the stream (shutdown) | |
| 214 | + | done := make(chan struct{}) | |
| 215 | + | go func() { _, _ = bufio.NewReader(resp.Body).ReadString(0); close(done) }() | |
| 216 | + | select { | |
| 217 | + | case <-done: | |
| 218 | + | case <-time.After(5 * time.Second): | |
| 219 | + | t.Fatal("the stream did not end with the API's") | |
| 220 | + | } | |
| 221 | + | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestEventsOffWithoutClient(t *testing.T) { | |
| 225 | + | srv, err := New(Config{ | |
| 226 | + | Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), | |
| 227 | + | Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), | |
| 228 | + | PublicURL: "https://forge.example", | |
| 229 | + | }) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | rec := httptest.NewRecorder() | |
| 232 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | |
| 233 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync/atomic" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "connectrpc.com/connect" | |||
| 17 | + | "github.com/stretchr/testify/assert" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ||||
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func TestFormatEvent(t *testing.T) { | |||
| 25 | + | assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n", | |||
| 26 | + | formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line") | |||
| 27 | + | assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) | |||
| 28 | + | assert.Equal(t, "id: b-2\n\n", formatEvent("b-2", "", "", false), "an id alone") | |||
| 29 | + | assert.Equal(t, "event: e\ndata: a\ndata: b\ndata: c\n\n", formatEvent("", "e", "a\r\nb\rc", true), "no line break escapes the field") | |||
| 30 | + | assert.Equal(t, "event: e\ndata: \n\n", formatEvent("", "e", "", true)) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestParseTopic(t *testing.T) { | |||
| 34 | + | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | |||
| 35 | + | repo, ok := parseTopic(in) | |||
| 36 | + | assert.True(t, ok, in) | |||
| 37 | + | assert.Equal(t, want, repo, in) | |||
| 38 | + | } | |||
| 39 | + | for _, in := range []string{"", "repo:", "other", "home,repo:x", "Repo:x", "repo"} { | |||
| 40 | + | _, ok := parseTopic(in) | |||
| 41 | + | assert.False(t, ok, in) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestLiveURL(t *testing.T) { | |||
| 46 | + | assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) | |||
| 47 | + | assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestNewCommitsText(t *testing.T) { | |||
| 51 | + | assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) | |||
| 52 | + | assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) | |||
| 53 | + | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // fakeEvents is an EventService whose behaviour a test sets. | |||
| 57 | + | type fakeEvents struct { | |||
| 58 | + | ermiteconnect.UnimplementedEventServiceHandler | |||
| 59 | + | err error | |||
| 60 | + | events []*pb.Event | |||
| 61 | + | opened atomic.Int32 | |||
| 62 | + | closed atomic.Int32 | |||
| 63 | + | resume atomic.Value | |||
| 64 | + | release chan struct{} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { | |||
| 68 | + | f.opened.Add(1) | |||
| 69 | + | defer f.closed.Add(1) | |||
| 70 | + | f.resume.Store(req.Msg.GetResumeAfter()) | |||
| 71 | + | if f.err != nil { | |||
| 72 | + | return f.err | |||
| 73 | + | } | |||
| 74 | + | _ = st.Send(nil) | |||
| 75 | + | for _, ev := range f.events { | |||
| 76 | + | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | select { | |||
| 81 | + | case <-ctx.Done(): | |||
| 82 | + | case <-f.release: | |||
| 83 | + | } | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // repoOK answers every GetRepo with a public repository. | |||
| 88 | + | type repoOK struct { | |||
| 89 | + | ermiteconnect.UnimplementedRepoServiceHandler | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 93 | + | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // eventsServer serves the web on a fake API, with a write timeout shorter | |||
| 97 | + | // than the heartbeat, like a server that sets one. | |||
| 98 | + | func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |||
| 99 | + | t.Helper() | |||
| 100 | + | api := http.NewServeMux() | |||
| 101 | + | api.Handle(ermiteconnect.NewEventServiceHandler(f)) | |||
| 102 | + | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | |||
| 103 | + | backend := httptest.NewServer(api) | |||
| 104 | + | t.Cleanup(backend.Close) | |||
| 105 | + | ||||
| 106 | + | srv, err := New(Config{ | |||
| 107 | + | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |||
| 108 | + | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 109 | + | Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), | |||
| 110 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 111 | + | Heartbeat: beat, | |||
| 112 | + | }) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | ts := httptest.NewUnstartedServer(srv) | |||
| 115 | + | ts.Config.WriteTimeout = writeTimeout | |||
| 116 | + | ts.Start() | |||
| 117 | + | t.Cleanup(ts.Close) | |||
| 118 | + | return ts | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string) (*http.Response, context.CancelFunc) { | |||
| 122 | + | t.Helper() | |||
| 123 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 124 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/-/events?"+query, nil) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | for k, v := range hdr { | |||
| 127 | + | req.Header.Set(k, v) | |||
| 128 | + | } | |||
| 129 | + | resp, err := ts.Client().Do(req) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | t.Cleanup(func() { cancel(); _ = resp.Body.Close() }) | |||
| 132 | + | return resp, cancel | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // An idle stream gets a comment every heartbeat, and the write timeout of the | |||
| 136 | + | // server does not end it. | |||
| 137 | + | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |||
| 138 | + | f := &fakeEvents{release: make(chan struct{})} | |||
| 139 | + | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | |||
| 140 | + | resp, cancel := open(t, ts, "topic=home", nil) | |||
| 141 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 142 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |||
| 143 | + | ||||
| 144 | + | sc := bufio.NewScanner(resp.Body) | |||
| 145 | + | beats := 0 | |||
| 146 | + | deadline := time.AfterFunc(5*time.Second, cancel) | |||
| 147 | + | defer deadline.Stop() | |||
| 148 | + | start := time.Now() | |||
| 149 | + | for sc.Scan() && beats < 8 { | |||
| 150 | + | if strings.HasPrefix(sc.Text(), ": heartbeat") { | |||
| 151 | + | beats++ | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | assert.Equal(t, 8, beats) | |||
| 155 | + | assert.Greater(t, time.Since(start), 250*time.Millisecond, "the stream outlived the 100 ms write timeout") | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // The id of the event, its name and the lines of its data come through; the | |||
| 159 | + | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | |||
| 160 | + | func TestEventsStreamsEventsAndResumes(t *testing.T) { | |||
| 161 | + | f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ | |||
| 162 | + | {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, | |||
| 163 | + | {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, | |||
| 164 | + | }} | |||
| 165 | + | ts := eventsServer(t, f, time.Hour, 0) | |||
| 166 | + | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | |||
| 167 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 168 | + | sc := bufio.NewScanner(resp.Body) | |||
| 169 | + | var lines []string | |||
| 170 | + | for sc.Scan() { | |||
| 171 | + | lines = append(lines, sc.Text()) | |||
| 172 | + | if sc.Text() == "data: gap" { | |||
| 173 | + | break | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | |||
| 177 | + | assert.Contains(t, lines, "event: gap") | |||
| 178 | + | assert.Equal(t, "b-6", f.resume.Load()) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Too many watchers is a 503 the browser can retry, not a stream. | |||
| 182 | + | func TestEventsResourceExhausted(t *testing.T) { | |||
| 183 | + | f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} | |||
| 184 | + | ts := eventsServer(t, f, time.Hour, 0) | |||
| 185 | + | resp, _ := open(t, ts, "topic=home", nil) | |||
| 186 | + | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |||
| 187 | + | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | |||
| 188 | + | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |||
| 189 | + | ||||
| 190 | + | f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) | |||
| 191 | + | resp, _ = open(t, ts, "topic=home", nil) | |||
| 192 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |||
| 193 | + | f.err = errors.New("boom") | |||
| 194 | + | resp, _ = open(t, ts, "topic=home", nil) | |||
| 195 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // The handler returns, and the watch it opened ends, when the visitor goes | |||
| 199 | + | // away or the API ends the stream. | |||
| 200 | + | func TestEventsNoLeak(t *testing.T) { | |||
| 201 | + | f := &fakeEvents{release: make(chan struct{})} | |||
| 202 | + | ts := eventsServer(t, f, time.Hour, 0) | |||
| 203 | + | ||||
| 204 | + | resp, cancel := open(t, ts, "topic=home", nil) | |||
| 205 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 206 | + | require.Eventually(t, func() bool { return f.opened.Load() == 1 }, 5*time.Second, time.Millisecond) | |||
| 207 | + | cancel() | |||
| 208 | + | require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the visitor left") | |||
| 209 | + | ||||
| 210 | + | resp, _ = open(t, ts, "topic=home", nil) | |||
| 211 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 212 | + | require.Eventually(t, func() bool { return f.opened.Load() == 2 }, 5*time.Second, time.Millisecond) | |||
| 213 | + | close(f.release) // the API ends the stream (shutdown) | |||
| 214 | + | done := make(chan struct{}) | |||
| 215 | + | go func() { _, _ = bufio.NewReader(resp.Body).ReadString(0); close(done) }() | |||
| 216 | + | select { | |||
| 217 | + | case <-done: | |||
| 218 | + | case <-time.After(5 * time.Second): | |||
| 219 | + | t.Fatal("the stream did not end with the API's") | |||
| 220 | + | } | |||
| 221 | + | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestEventsOffWithoutClient(t *testing.T) { | |||
| 225 | + | srv, err := New(Config{ | |||
| 226 | + | Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), | |||
| 227 | + | Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), | |||
| 228 | + | PublicURL: "https://forge.example", | |||
| 229 | + | }) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | rec := httptest.NewRecorder() | |||
| 232 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | |||
| 233 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 234 | + | } | |||
A internal/adapters/web/fragments.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "strconv" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/a-h/templ" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // liveStream turns the events of Watch into the fragments one page shows. | |
| 17 | + | type liveStream struct { | |
| 18 | + | s *Server | |
| 19 | + | repo string // "" on the home page | |
| 20 | + | view string // "summary", "refs" or "commits" | |
| 21 | + | ref string // commits: the branch listed | |
| 22 | + | head string // commits: the commit listed first | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // renderFragment renders a component to the data of an event. | |
| 26 | + | func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) { | |
| 27 | + | var b bytes.Buffer | |
| 28 | + | if err := c.Render(ctx, &b); err != nil { | |
| 29 | + | return fragment{}, err | |
| 30 | + | } | |
| 31 | + | return fragment{event: event, html: b.String()}, nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | var gap = []fragment{{event: "gap", html: "gap"}} | |
| 35 | + | ||
| 36 | + | // render returns the fragments for an event, none when it changes nothing on | |
| 37 | + | // the page. | |
| 38 | + | func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 39 | + | if ev == nil { | |
| 40 | + | return nil, nil | |
| 41 | + | } | |
| 42 | + | if ev.GetGap() != nil { | |
| 43 | + | return gap, nil | |
| 44 | + | } | |
| 45 | + | if l.repo == "" { | |
| 46 | + | return l.home(ctx, ev) | |
| 47 | + | } | |
| 48 | + | return l.repoPage(ctx, ev) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // home upserts the row of the repository an event is about. The row is read | |
| 52 | + | // again from the API as the visitor of the stream, so a repository that was | |
| 53 | + | // deleted or that the visitor can no longer see (public to private) removes | |
| 54 | + | // the row, and the stream never carries more than the list shows. | |
| 55 | + | func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 56 | + | var name string | |
| 57 | + | fresh := false | |
| 58 | + | switch k := ev.GetKind().(type) { | |
| 59 | + | case *pb.Event_RepoDeleted: | |
| 60 | + | return l.gone(ctx, k.RepoDeleted.GetRepo()) | |
| 61 | + | case *pb.Event_RepoCreated: | |
| 62 | + | name = k.RepoCreated.GetRepo().GetName() | |
| 63 | + | case *pb.Event_RepoUpdated: | |
| 64 | + | name = k.RepoUpdated.GetRepo().GetName() | |
| 65 | + | case *pb.Event_Pushed: | |
| 66 | + | name, fresh = k.Pushed.GetRepo(), true | |
| 67 | + | default: | |
| 68 | + | return nil, nil | |
| 69 | + | } | |
| 70 | + | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |
| 71 | + | switch { | |
| 72 | + | case connect.CodeOf(err) == connect.CodeNotFound: | |
| 73 | + | return l.gone(ctx, name) | |
| 74 | + | case err != nil: | |
| 75 | + | return nil, err | |
| 76 | + | } | |
| 77 | + | row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false) | |
| 78 | + | row.Fresh = fresh | |
| 79 | + | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | |
| 80 | + | return []fragment{f}, err | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) { | |
| 84 | + | f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name)) | |
| 85 | + | return []fragment{f}, err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // repoPage handles the events of the topic of one repository. | |
| 89 | + | func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 90 | + | switch k := ev.GetKind().(type) { | |
| 91 | + | case *pb.Event_RepoDeleted, *pb.Event_RepoCreated: | |
| 92 | + | // Gone, or a new repository under the same name: whatever the page | |
| 93 | + | // shows is not true any more. | |
| 94 | + | return gap, nil | |
| 95 | + | case *pb.Event_RepoUpdated: | |
| 96 | + | _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |
| 97 | + | switch { | |
| 98 | + | case connect.CodeOf(err) == connect.CodeNotFound: | |
| 99 | + | return gap, nil // no longer visible | |
| 100 | + | case err != nil: | |
| 101 | + | return nil, err | |
| 102 | + | case l.view == "summary": | |
| 103 | + | return gap, nil // the description, the default branch | |
| 104 | + | } | |
| 105 | + | return nil, nil | |
| 106 | + | case *pb.Event_Pushed: | |
| 107 | + | switch l.view { | |
| 108 | + | case "refs": | |
| 109 | + | return l.refs(ctx) | |
| 110 | + | case "commits": | |
| 111 | + | return l.banner(ctx, k.Pushed) | |
| 112 | + | } | |
| 113 | + | return l.lastPush(ctx) | |
| 114 | + | } | |
| 115 | + | return nil, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // lastPush is the "last push" fact of the summary. | |
| 119 | + | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | |
| 120 | + | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |
| 121 | + | if err != nil { | |
| 122 | + | return nil, err | |
| 123 | + | } | |
| 124 | + | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now()))) | |
| 125 | + | return []fragment{f}, err | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // refs is the table of the refs page and the facts of its rail. | |
| 129 | + | func (l *liveStream) refs(ctx context.Context) ([]fragment, error) { | |
| 130 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 131 | + | if err != nil { | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | p := l.s.refsPage(ctx, d) | |
| 135 | + | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | |
| 136 | + | if err != nil { | |
| 137 | + | return nil, err | |
| 138 | + | } | |
| 139 | + | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | |
| 140 | + | return []fragment{body, kv}, err | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // banner tells the commits page how many commits its branch got since the | |
| 144 | + | // head it lists. | |
| 145 | + | func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | |
| 146 | + | if l.ref == "" || !isFullID(l.head) { | |
| 147 | + | return nil, nil | |
| 148 | + | } | |
| 149 | + | tip, touched := "", false | |
| 150 | + | for _, u := range p.GetRefUpdates() { | |
| 151 | + | if u.GetRef() == "refs/heads/"+l.ref { | |
| 152 | + | tip, touched = u.GetNewId(), true | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | switch { | |
| 156 | + | case !touched: | |
| 157 | + | return nil, nil | |
| 158 | + | case tip == "": | |
| 159 | + | return gap, nil // the branch was deleted | |
| 160 | + | } | |
| 161 | + | text := "" | |
| 162 | + | if tip != l.head { | |
| 163 | + | res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 164 | + | Repo: l.repo, Revision: tip, PageSize: pageSize, | |
| 165 | + | })) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, err | |
| 168 | + | } | |
| 169 | + | n, found := 0, false | |
| 170 | + | for _, c := range res.Msg.GetCommits() { | |
| 171 | + | if c.GetId() == l.head { | |
| 172 | + | found = true | |
| 173 | + | break | |
| 174 | + | } | |
| 175 | + | n++ | |
| 176 | + | } | |
| 177 | + | switch { | |
| 178 | + | case found: | |
| 179 | + | text = newCommits(strconv.Itoa(n), n) | |
| 180 | + | case res.Msg.GetNextPageToken() != "": | |
| 181 | + | text = newCommits(strconv.Itoa(n)+"+", 2) | |
| 182 | + | default: | |
| 183 | + | text = "the branch was rewritten — reload" | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | c := CommitsSlot() | |
| 187 | + | if text != "" { | |
| 188 | + | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | |
| 189 | + | } | |
| 190 | + | f, err := renderFragment(ctx, "commits-head", c) | |
| 191 | + | return []fragment{f}, err | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // newCommits is the text of the banner: "1 new commit — reload". | |
| 195 | + | func newCommits(count string, n int) string { | |
| 196 | + | if n == 1 { | |
| 197 | + | return count + " new commit — reload" | |
| 198 | + | } | |
| 199 | + | return count + " new commits — reload" | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // isFullID reports whether s is a full commit id. | |
| 203 | + | func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "strconv" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/a-h/templ" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // liveStream turns the events of Watch into the fragments one page shows. | |||
| 17 | + | type liveStream struct { | |||
| 18 | + | s *Server | |||
| 19 | + | repo string // "" on the home page | |||
| 20 | + | view string // "summary", "refs" or "commits" | |||
| 21 | + | ref string // commits: the branch listed | |||
| 22 | + | head string // commits: the commit listed first | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // renderFragment renders a component to the data of an event. | |||
| 26 | + | func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) { | |||
| 27 | + | var b bytes.Buffer | |||
| 28 | + | if err := c.Render(ctx, &b); err != nil { | |||
| 29 | + | return fragment{}, err | |||
| 30 | + | } | |||
| 31 | + | return fragment{event: event, html: b.String()}, nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | var gap = []fragment{{event: "gap", html: "gap"}} | |||
| 35 | + | ||||
| 36 | + | // render returns the fragments for an event, none when it changes nothing on | |||
| 37 | + | // the page. | |||
| 38 | + | func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |||
| 39 | + | if ev == nil { | |||
| 40 | + | return nil, nil | |||
| 41 | + | } | |||
| 42 | + | if ev.GetGap() != nil { | |||
| 43 | + | return gap, nil | |||
| 44 | + | } | |||
| 45 | + | if l.repo == "" { | |||
| 46 | + | return l.home(ctx, ev) | |||
| 47 | + | } | |||
| 48 | + | return l.repoPage(ctx, ev) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // home upserts the row of the repository an event is about. The row is read | |||
| 52 | + | // again from the API as the visitor of the stream, so a repository that was | |||
| 53 | + | // deleted or that the visitor can no longer see (public to private) removes | |||
| 54 | + | // the row, and the stream never carries more than the list shows. | |||
| 55 | + | func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |||
| 56 | + | var name string | |||
| 57 | + | fresh := false | |||
| 58 | + | switch k := ev.GetKind().(type) { | |||
| 59 | + | case *pb.Event_RepoDeleted: | |||
| 60 | + | return l.gone(ctx, k.RepoDeleted.GetRepo()) | |||
| 61 | + | case *pb.Event_RepoCreated: | |||
| 62 | + | name = k.RepoCreated.GetRepo().GetName() | |||
| 63 | + | case *pb.Event_RepoUpdated: | |||
| 64 | + | name = k.RepoUpdated.GetRepo().GetName() | |||
| 65 | + | case *pb.Event_Pushed: | |||
| 66 | + | name, fresh = k.Pushed.GetRepo(), true | |||
| 67 | + | default: | |||
| 68 | + | return nil, nil | |||
| 69 | + | } | |||
| 70 | + | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |||
| 71 | + | switch { | |||
| 72 | + | case connect.CodeOf(err) == connect.CodeNotFound: | |||
| 73 | + | return l.gone(ctx, name) | |||
| 74 | + | case err != nil: | |||
| 75 | + | return nil, err | |||
| 76 | + | } | |||
| 77 | + | row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false) | |||
| 78 | + | row.Fresh = fresh | |||
| 79 | + | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | |||
| 80 | + | return []fragment{f}, err | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) { | |||
| 84 | + | f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name)) | |||
| 85 | + | return []fragment{f}, err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // repoPage handles the events of the topic of one repository. | |||
| 89 | + | func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |||
| 90 | + | switch k := ev.GetKind().(type) { | |||
| 91 | + | case *pb.Event_RepoDeleted, *pb.Event_RepoCreated: | |||
| 92 | + | // Gone, or a new repository under the same name: whatever the page | |||
| 93 | + | // shows is not true any more. | |||
| 94 | + | return gap, nil | |||
| 95 | + | case *pb.Event_RepoUpdated: | |||
| 96 | + | _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |||
| 97 | + | switch { | |||
| 98 | + | case connect.CodeOf(err) == connect.CodeNotFound: | |||
| 99 | + | return gap, nil // no longer visible | |||
| 100 | + | case err != nil: | |||
| 101 | + | return nil, err | |||
| 102 | + | case l.view == "summary": | |||
| 103 | + | return gap, nil // the description, the default branch | |||
| 104 | + | } | |||
| 105 | + | return nil, nil | |||
| 106 | + | case *pb.Event_Pushed: | |||
| 107 | + | switch l.view { | |||
| 108 | + | case "refs": | |||
| 109 | + | return l.refs(ctx) | |||
| 110 | + | case "commits": | |||
| 111 | + | return l.banner(ctx, k.Pushed) | |||
| 112 | + | } | |||
| 113 | + | return l.lastPush(ctx) | |||
| 114 | + | } | |||
| 115 | + | return nil, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // lastPush is the "last push" fact of the summary. | |||
| 119 | + | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | |||
| 120 | + | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return nil, err | |||
| 123 | + | } | |||
| 124 | + | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now()))) | |||
| 125 | + | return []fragment{f}, err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // refs is the table of the refs page and the facts of its rail. | |||
| 129 | + | func (l *liveStream) refs(ctx context.Context) ([]fragment, error) { | |||
| 130 | + | d, err := l.s.loadRepo(ctx, l.repo) | |||
| 131 | + | if err != nil { | |||
| 132 | + | return nil, err | |||
| 133 | + | } | |||
| 134 | + | p := l.s.refsPage(ctx, d) | |||
| 135 | + | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return nil, err | |||
| 138 | + | } | |||
| 139 | + | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | |||
| 140 | + | return []fragment{body, kv}, err | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // banner tells the commits page how many commits its branch got since the | |||
| 144 | + | // head it lists. | |||
| 145 | + | func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | |||
| 146 | + | if l.ref == "" || !isFullID(l.head) { | |||
| 147 | + | return nil, nil | |||
| 148 | + | } | |||
| 149 | + | tip, touched := "", false | |||
| 150 | + | for _, u := range p.GetRefUpdates() { | |||
| 151 | + | if u.GetRef() == "refs/heads/"+l.ref { | |||
| 152 | + | tip, touched = u.GetNewId(), true | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | switch { | |||
| 156 | + | case !touched: | |||
| 157 | + | return nil, nil | |||
| 158 | + | case tip == "": | |||
| 159 | + | return gap, nil // the branch was deleted | |||
| 160 | + | } | |||
| 161 | + | text := "" | |||
| 162 | + | if tip != l.head { | |||
| 163 | + | res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |||
| 164 | + | Repo: l.repo, Revision: tip, PageSize: pageSize, | |||
| 165 | + | })) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return nil, err | |||
| 168 | + | } | |||
| 169 | + | n, found := 0, false | |||
| 170 | + | for _, c := range res.Msg.GetCommits() { | |||
| 171 | + | if c.GetId() == l.head { | |||
| 172 | + | found = true | |||
| 173 | + | break | |||
| 174 | + | } | |||
| 175 | + | n++ | |||
| 176 | + | } | |||
| 177 | + | switch { | |||
| 178 | + | case found: | |||
| 179 | + | text = newCommits(strconv.Itoa(n), n) | |||
| 180 | + | case res.Msg.GetNextPageToken() != "": | |||
| 181 | + | text = newCommits(strconv.Itoa(n)+"+", 2) | |||
| 182 | + | default: | |||
| 183 | + | text = "the branch was rewritten — reload" | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | c := CommitsSlot() | |||
| 187 | + | if text != "" { | |||
| 188 | + | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | |||
| 189 | + | } | |||
| 190 | + | f, err := renderFragment(ctx, "commits-head", c) | |||
| 191 | + | return []fragment{f}, err | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // newCommits is the text of the banner: "1 new commit — reload". | |||
| 195 | + | func newCommits(count string, n int) string { | |||
| 196 | + | if n == 1 { | |||
| 197 | + | return count + " new commit — reload" | |||
| 198 | + | } | |||
| 199 | + | return count + " new commits — reload" | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // isFullID reports whether s is a full commit id. | |||
| 203 | + | func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) } | |||
A internal/adapters/web/fragments.templ +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // The fragments of the live updates (events.go). Each is a top-level element | |
| 6 | + | // with an id: the page swaps the element that has the same id. | |
| 7 | + | ||
| 8 | + | // FactValue is the value of a fact; its id, when it has one, is the target of | |
| 9 | + | // the live updates ("last push"). | |
| 10 | + | templ FactValue(f Fact) { | |
| 11 | + | <dd | |
| 12 | + | if f.ID != "" { | |
| 13 | + | id={ f.ID } | |
| 14 | + | } | |
| 15 | + | > | |
| 16 | + | if f.Time != "" { | |
| 17 | + | <time datetime={ f.Time }>{ f.Val }</time> | |
| 18 | + | } else { | |
| 19 | + | { f.Val } | |
| 20 | + | } | |
| 21 | + | </dd> | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // RepoGone replaces the row of a repository that is deleted or not visible | |
| 25 | + | // any more; the page removes the element. | |
| 26 | + | templ RepoGone(id string) { | |
| 27 | + | <tr id={ id } data-gone hidden></tr> | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // RefsBody is the rows of the refs table. | |
| 31 | + | templ RefsBody(p RefsPage) { | |
| 32 | + | <tbody id="ref-rows" data-rows> | |
| 33 | + | for _, b := range p.Branches { | |
| 34 | + | @refRow("branch", b) | |
| 35 | + | } | |
| 36 | + | for _, t := range p.Tags { | |
| 37 | + | @refRow("tag", t) | |
| 38 | + | } | |
| 39 | + | </tbody> | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // RefsKV is the facts of the rail of the refs page. | |
| 43 | + | templ RefsKV(p RefsPage) { | |
| 44 | + | <dl class="kv" id="refs-kv"> | |
| 45 | + | <dt>default</dt> | |
| 46 | + | <dd>{ p.Default }</dd> | |
| 47 | + | <dt>branches</dt> | |
| 48 | + | <dd>{ itoa(len(p.Branches)) }</dd> | |
| 49 | + | <dt>tags</dt> | |
| 50 | + | <dd>{ itoa(len(p.Tags)) }</dd> | |
| 51 | + | if p.Latest != "" { | |
| 52 | + | <dt>latest tag</dt> | |
| 53 | + | <dd>{ p.Latest }</dd> | |
| 54 | + | } | |
| 55 | + | </dl> | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // CommitsSlot is the place of the banner of the commits page, empty. | |
| 59 | + | templ CommitsSlot() { | |
| 60 | + | <div id="commits-head"></div> | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // CommitsBanner announces new commits on the branch the page lists. It links | |
| 64 | + | // to the first page of the branch. | |
| 65 | + | templ CommitsBanner(text, href string) { | |
| 66 | + | <div id="commits-head" class="live-banner" role="status"> | |
| 67 | + | <a href={ templ.SafeURL(href) }>{ text }</a> | |
| 68 | + | </div> | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Live is the connection state of the event stream, in the status bar. | |
| 72 | + | // Without a stream it reads "offline" and does nothing. The events of the | |
| 73 | + | // stream are fragments that ermiteLive (ermite.js) swaps in the page. | |
| 74 | + | templ Live(l Layout) { | |
| 75 | + | if l.Live && l.LiveURL != "" { | |
| 76 | + | <span | |
| 77 | + | class="st-live" | |
| 78 | + | data-state="offline" | |
| 79 | + | role="status" | |
| 80 | + | hx-sse:connect={ l.LiveURL } | |
| 81 | + | hx-swap="none" | |
| 82 | + | :text="data.state" | |
| 83 | + | :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" | |
| 84 | + | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 85 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 86 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |
| 87 | + | gap -> ermiteLive.reload()" | |
| 88 | + | >offline</span> | |
| 89 | + | } else { | |
| 90 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 91 | + | } | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // The fragments of the live updates (events.go). Each is a top-level element | |||
| 6 | + | // with an id: the page swaps the element that has the same id. | |||
| 7 | + | ||||
| 8 | + | // FactValue is the value of a fact; its id, when it has one, is the target of | |||
| 9 | + | // the live updates ("last push"). | |||
| 10 | + | templ FactValue(f Fact) { | |||
| 11 | + | <dd | |||
| 12 | + | if f.ID != "" { | |||
| 13 | + | id={ f.ID } | |||
| 14 | + | } | |||
| 15 | + | > | |||
| 16 | + | if f.Time != "" { | |||
| 17 | + | <time datetime={ f.Time }>{ f.Val }</time> | |||
| 18 | + | } else { | |||
| 19 | + | { f.Val } | |||
| 20 | + | } | |||
| 21 | + | </dd> | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // RepoGone replaces the row of a repository that is deleted or not visible | |||
| 25 | + | // any more; the page removes the element. | |||
| 26 | + | templ RepoGone(id string) { | |||
| 27 | + | <tr id={ id } data-gone hidden></tr> | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // RefsBody is the rows of the refs table. | |||
| 31 | + | templ RefsBody(p RefsPage) { | |||
| 32 | + | <tbody id="ref-rows" data-rows> | |||
| 33 | + | for _, b := range p.Branches { | |||
| 34 | + | @refRow("branch", b) | |||
| 35 | + | } | |||
| 36 | + | for _, t := range p.Tags { | |||
| 37 | + | @refRow("tag", t) | |||
| 38 | + | } | |||
| 39 | + | </tbody> | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // RefsKV is the facts of the rail of the refs page. | |||
| 43 | + | templ RefsKV(p RefsPage) { | |||
| 44 | + | <dl class="kv" id="refs-kv"> | |||
| 45 | + | <dt>default</dt> | |||
| 46 | + | <dd>{ p.Default }</dd> | |||
| 47 | + | <dt>branches</dt> | |||
| 48 | + | <dd>{ itoa(len(p.Branches)) }</dd> | |||
| 49 | + | <dt>tags</dt> | |||
| 50 | + | <dd>{ itoa(len(p.Tags)) }</dd> | |||
| 51 | + | if p.Latest != "" { | |||
| 52 | + | <dt>latest tag</dt> | |||
| 53 | + | <dd>{ p.Latest }</dd> | |||
| 54 | + | } | |||
| 55 | + | </dl> | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // CommitsSlot is the place of the banner of the commits page, empty. | |||
| 59 | + | templ CommitsSlot() { | |||
| 60 | + | <div id="commits-head"></div> | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // CommitsBanner announces new commits on the branch the page lists. It links | |||
| 64 | + | // to the first page of the branch. | |||
| 65 | + | templ CommitsBanner(text, href string) { | |||
| 66 | + | <div id="commits-head" class="live-banner" role="status"> | |||
| 67 | + | <a href={ templ.SafeURL(href) }>{ text }</a> | |||
| 68 | + | </div> | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Live is the connection state of the event stream, in the status bar. | |||
| 72 | + | // Without a stream it reads "offline" and does nothing. The events of the | |||
| 73 | + | // stream are fragments that ermiteLive (ermite.js) swaps in the page. | |||
| 74 | + | templ Live(l Layout) { | |||
| 75 | + | if l.Live && l.LiveURL != "" { | |||
| 76 | + | <span | |||
| 77 | + | class="st-live" | |||
| 78 | + | data-state="offline" | |||
| 79 | + | role="status" | |||
| 80 | + | hx-sse:connect={ l.LiveURL } | |||
| 81 | + | hx-swap="none" | |||
| 82 | + | :text="data.state" | |||
| 83 | + | :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" | |||
| 84 | + | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |||
| 85 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 86 | + | home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data); | |||
| 87 | + | gap -> ermiteLive.reload()" | |||
| 88 | + | >offline</span> | |||
| 89 | + | } else { | |||
| 90 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 91 | + | } | |||
| 92 | + | } | |||
A internal/adapters/web/fragments_templ.go +437 -0 generated file: collapsed
M internal/adapters/web/home.go +1 -0
| @@ -108,6 +108,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 108 | 108 | ||
| 109 | 109 | l := s.base() | |
| 110 | 110 | l.Title = s.instance | |
| 111 | + | l.Live, l.LiveURL = true, liveURL("home", "") | |
| 111 | 112 | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | |
| 112 | 113 | dir := "▲" | |
| 113 | 114 | if desc { | |
| @@ -108,6 +108,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 108 | 108 | ||||
| 109 | l := s.base() | 109 | l := s.base() | ||
| 110 | l.Title = s.instance | 110 | l.Title = s.instance | ||
| 111 | + | l.Live, l.LiveURL = true, liveURL("home", "") | |||
| 111 | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | 112 | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | ||
| 112 | dir := "▲" | 113 | dir := "▲" | ||
| 113 | if desc { | 114 | if desc { | ||
M internal/adapters/web/pages.templ +2 -20
| @@ -92,14 +92,7 @@ templ Refs(l Layout, p RefsPage) { | |||
| 92 | 92 | <thead> | |
| 93 | 93 | <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | |
| 94 | 94 | </thead> | |
| 95 | - | <tbody id="ref-rows" data-rows> | |
| 96 | - | for _, b := range p.Branches { | |
| 97 | - | @refRow("branch", b) | |
| 98 | - | } | |
| 99 | - | for _, t := range p.Tags { | |
| 100 | - | @refRow("tag", t) | |
| 101 | - | } | |
| 102 | - | </tbody> | |
| 95 | + | @RefsBody(p) | |
| 103 | 96 | </table> | |
| 104 | 97 | if p.Note != "" { | |
| 105 | 98 | <p class="note">{ p.Note }</p> | |
| @@ -136,18 +129,7 @@ templ refRow(kind string, r RefRow) { | |||
| 136 | 129 | templ refsRail(p RefsPage) { | |
| 137 | 130 | <section aria-labelledby="r-refs"> | |
| 138 | 131 | <h2 id="r-refs">refs</h2> | |
| 139 | - | <dl class="kv"> | |
| 140 | - | <dt>default</dt> | |
| 141 | - | <dd>{ p.Default }</dd> | |
| 142 | - | <dt>branches</dt> | |
| 143 | - | <dd>{ itoa(len(p.Branches)) }</dd> | |
| 144 | - | <dt>tags</dt> | |
| 145 | - | <dd>{ itoa(len(p.Tags)) }</dd> | |
| 146 | - | if p.Latest != "" { | |
| 147 | - | <dt>latest tag</dt> | |
| 148 | - | <dd>{ p.Latest }</dd> | |
| 149 | - | } | |
| 150 | - | </dl> | |
| 132 | + | @RefsKV(p) | |
| 151 | 133 | </section> | |
| 152 | 134 | <section aria-labelledby="r-legend"> | |
| 153 | 135 | <h2 id="r-legend">legend</h2> | |
| @@ -92,14 +92,7 @@ templ Refs(l Layout, p RefsPage) { | |||||
| 92 | <thead> | 92 | <thead> | ||
| 93 | <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | 93 | <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | ||
| 94 | </thead> | 94 | </thead> | ||
| 95 | - | <tbody id="ref-rows" data-rows> | 95 | + | @RefsBody(p) |
| 96 | - | for _, b := range p.Branches { | |||
| 97 | - | @refRow("branch", b) | |||
| 98 | - | } | |||
| 99 | - | for _, t := range p.Tags { | |||
| 100 | - | @refRow("tag", t) | |||
| 101 | - | } | |||
| 102 | - | </tbody> | |||
| 103 | </table> | 96 | </table> | ||
| 104 | if p.Note != "" { | 97 | if p.Note != "" { | ||
| 105 | <p class="note">{ p.Note }</p> | 98 | <p class="note">{ p.Note }</p> | ||
| @@ -136,18 +129,7 @@ templ refRow(kind string, r RefRow) { | |||||
| 136 | templ refsRail(p RefsPage) { | 129 | templ refsRail(p RefsPage) { | ||
| 137 | <section aria-labelledby="r-refs"> | 130 | <section aria-labelledby="r-refs"> | ||
| 138 | <h2 id="r-refs">refs</h2> | 131 | <h2 id="r-refs">refs</h2> | ||
| 139 | - | <dl class="kv"> | 132 | + | @RefsKV(p) |
| 140 | - | <dt>default</dt> | |||
| 141 | - | <dd>{ p.Default }</dd> | |||
| 142 | - | <dt>branches</dt> | |||
| 143 | - | <dd>{ itoa(len(p.Branches)) }</dd> | |||
| 144 | - | <dt>tags</dt> | |||
| 145 | - | <dd>{ itoa(len(p.Tags)) }</dd> | |||
| 146 | - | if p.Latest != "" { | |||
| 147 | - | <dt>latest tag</dt> | |||
| 148 | - | <dd>{ p.Latest }</dd> | |||
| 149 | - | } | |||
| 150 | - | </dl> | |||
| 151 | </section> | 133 | </section> | ||
| 152 | <section aria-labelledby="r-legend"> | 134 | <section aria-labelledby="r-legend"> | ||
| 153 | <h2 id="r-legend">legend</h2> | 135 | <h2 id="r-legend">legend</h2> | ||
M internal/adapters/web/pages_templ.go +54 -116 generated file: collapsed
M internal/adapters/web/refspage.go +19 -11
| @@ -19,6 +19,24 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 19 | 19 | if err != nil { | |
| 20 | 20 | return nil, 0, err | |
| 21 | 21 | } | |
| 22 | + | p := s.refsPage(ctx, d) | |
| 23 | + | ||
| 24 | + | l := s.repoLayout(ctx, d, "refs") | |
| 25 | + | l.Title = "refs · " + d.name() + " · " + s.instance | |
| 26 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs") | |
| 27 | + | l.Crumbs[0].Current = false | |
| 28 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |
| 29 | + | l.Status = Status{ | |
| 30 | + | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |
| 31 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |
| 32 | + | } | |
| 33 | + | l.HasRail, l.RailSum = true, "refs · legend" | |
| 34 | + | return Refs(l, p), http.StatusOK, nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // refsPage is the content of the refs page, which the live updates of the | |
| 38 | + | // page render again. | |
| 39 | + | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |
| 22 | 40 | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 23 | 41 | commits := s.commitsOf(ctx, d.name(), all) | |
| 24 | 42 | ||
| @@ -45,17 +63,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 45 | 63 | if d.cut { | |
| 46 | 64 | p.Note = "this repository has more refs than are listed here." | |
| 47 | 65 | } | |
| 48 | - | ||
| 49 | - | l := s.repoLayout(ctx, d, "refs") | |
| 50 | - | l.Title = "refs · " + d.name() + " · " + s.instance | |
| 51 | - | l.Crumbs[0].Current = false | |
| 52 | - | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |
| 53 | - | l.Status = Status{ | |
| 54 | - | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |
| 55 | - | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |
| 56 | - | } | |
| 57 | - | l.HasRail, l.RailSum = true, "refs · legend" | |
| 58 | - | return Refs(l, p), http.StatusOK, nil | |
| 66 | + | return p | |
| 59 | 67 | } | |
| 60 | 68 | ||
| 61 | 69 | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| @@ -19,6 +19,24 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 19 | if err != nil { | 19 | if err != nil { | ||
| 20 | return nil, 0, err | 20 | return nil, 0, err | ||
| 21 | } | 21 | } | ||
| 22 | + | p := s.refsPage(ctx, d) | |||
| 23 | + | ||||
| 24 | + | l := s.repoLayout(ctx, d, "refs") | |||
| 25 | + | l.Title = "refs · " + d.name() + " · " + s.instance | |||
| 26 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs") | |||
| 27 | + | l.Crumbs[0].Current = false | |||
| 28 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |||
| 29 | + | l.Status = Status{ | |||
| 30 | + | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |||
| 31 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |||
| 32 | + | } | |||
| 33 | + | l.HasRail, l.RailSum = true, "refs · legend" | |||
| 34 | + | return Refs(l, p), http.StatusOK, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // refsPage is the content of the refs page, which the live updates of the | |||
| 38 | + | // page render again. | |||
| 39 | + | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 22 | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | 40 | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | ||
| 23 | commits := s.commitsOf(ctx, d.name(), all) | 41 | commits := s.commitsOf(ctx, d.name(), all) | ||
| 24 | 42 | ||||
| @@ -45,17 +63,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 45 | if d.cut { | 63 | if d.cut { | ||
| 46 | p.Note = "this repository has more refs than are listed here." | 64 | p.Note = "this repository has more refs than are listed here." | ||
| 47 | } | 65 | } | ||
| 48 | - | 66 | + | return p | |
| 49 | - | l := s.repoLayout(ctx, d, "refs") | |||
| 50 | - | l.Title = "refs · " + d.name() + " · " + s.instance | |||
| 51 | - | l.Crumbs[0].Current = false | |||
| 52 | - | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |||
| 53 | - | l.Status = Status{ | |||
| 54 | - | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |||
| 55 | - | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |||
| 56 | - | } | |||
| 57 | - | l.HasRail, l.RailSum = true, "refs · legend" | |||
| 58 | - | return Refs(l, p), http.StatusOK, nil | |||
| 59 | } | 67 | } | ||
| 60 | 68 | ||||
| 61 | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | 69 | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | ||
M internal/adapters/web/shell.templ +1 -22
| @@ -119,7 +119,7 @@ templ Statusbar(l Layout) { | |||
| 119 | 119 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | |
| 120 | 120 | <span class="st-hints">{ l.Status.Hints }</span> | |
| 121 | 121 | @Footer(l) | |
| 122 | - | @Live(l.Live) | |
| 122 | + | @Live(l) | |
| 123 | 123 | if l.Status.Cursor { | |
| 124 | 124 | <span | |
| 125 | 125 | class="st-pos num" | |
| @@ -135,24 +135,3 @@ templ Statusbar(l Layout) { | |||
| 135 | 135 | templ Footer(l Layout) { | |
| 136 | 136 | <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span> | |
| 137 | 137 | } | |
| 138 | - | ||
| 139 | - | // Live is the connection state of the event stream. Without the stream it | |
| 140 | - | // reads "offline" and does nothing. | |
| 141 | - | templ Live(on bool) { | |
| 142 | - | if on { | |
| 143 | - | <span | |
| 144 | - | class="st-live" | |
| 145 | - | data-state="offline" | |
| 146 | - | role="status" | |
| 147 | - | hx-sse:connect="/-/events" | |
| 148 | - | hx-swap="none" | |
| 149 | - | :text="data.state" | |
| 150 | - | :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'" | |
| 151 | - | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 152 | - | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 153 | - | push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)" | |
| 154 | - | >offline</span> | |
| 155 | - | } else { | |
| 156 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 157 | - | } | |
| 158 | - | } | |
| @@ -119,7 +119,7 @@ templ Statusbar(l Layout) { | |||||
| 119 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | 119 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | ||
| 120 | <span class="st-hints">{ l.Status.Hints }</span> | 120 | <span class="st-hints">{ l.Status.Hints }</span> | ||
| 121 | @Footer(l) | 121 | @Footer(l) | ||
| 122 | - | @Live(l.Live) | 122 | + | @Live(l) |
| 123 | if l.Status.Cursor { | 123 | if l.Status.Cursor { | ||
| 124 | <span | 124 | <span | ||
| 125 | class="st-pos num" | 125 | class="st-pos num" | ||
| @@ -135,24 +135,3 @@ templ Statusbar(l Layout) { | |||||
| 135 | templ Footer(l Layout) { | 135 | templ Footer(l Layout) { | ||
| 136 | <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span> | 136 | <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span> | ||
| 137 | } | 137 | } | ||
| 138 | - | ||||
| 139 | - | // Live is the connection state of the event stream. Without the stream it | |||
| 140 | - | // reads "offline" and does nothing. | |||
| 141 | - | templ Live(on bool) { | |||
| 142 | - | if on { | |||
| 143 | - | <span | |||
| 144 | - | class="st-live" | |||
| 145 | - | data-state="offline" | |||
| 146 | - | role="status" | |||
| 147 | - | hx-sse:connect="/-/events" | |||
| 148 | - | hx-swap="none" | |||
| 149 | - | :text="data.state" | |||
| 150 | - | :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'" | |||
| 151 | - | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |||
| 152 | - | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 153 | - | push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)" | |||
| 154 | - | >offline</span> | |||
| 155 | - | } else { | |||
| 156 | - | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 157 | - | } | |||
| 158 | - | } | |||
M internal/adapters/web/shell_templ.go +1 -39 generated file: collapsed
A internal/adapters/web/static/css/80-live.css +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* live updates: the banner of new commits, an empty slot takes no room */ | |
| 3 | + | #commits-head:empty { display: none; } | |
| 4 | + | .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; } | |
| 5 | + | .live-banner a { color: var(--ink); } | |
| 6 | + | } | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* live updates: the banner of new commits, an empty slot takes no room */ | |||
| 3 | + | #commits-head:empty { display: none; } | |||
| 4 | + | .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; } | |||
| 5 | + | .live-banner a { color: var(--ink); } | |||
| 6 | + | } | |||
M internal/adapters/web/static/js/ermite.js +34 -0
| @@ -30,3 +30,37 @@ | |||
| 30 | 30 | // PageUp/PageDown and space scroll the content without a click first. | |
| 31 | 31 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |
| 32 | 32 | })(); | |
| 33 | + | ||
| 34 | + | // ermiteLive applies the fragments of the event stream (/-/events, see | |
| 35 | + | // internal/adapters/web/events.go). A fragment is a top-level element with an | |
| 36 | + | // id: it replaces the element of the page that has the same id. A fragment | |
| 37 | + | // marked data-gone removes it; a row of a repository the home page does not | |
| 38 | + | // list yet is added at the top of the list. A gap, which says the page may | |
| 39 | + | // have missed events or that what it shows changed, reloads the page. | |
| 40 | + | window.ermiteLive = (() => { | |
| 41 | + | let reloading = false; | |
| 42 | + | return { | |
| 43 | + | swap(html) { | |
| 44 | + | const t = document.createElement('template'); | |
| 45 | + | t.innerHTML = html; | |
| 46 | + | for (const el of t.content.children) { | |
| 47 | + | const old = el.id && document.getElementById(el.id); | |
| 48 | + | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | |
| 49 | + | if (!old) { | |
| 50 | + | const rows = document.getElementById('repo-rows'); | |
| 51 | + | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | |
| 52 | + | continue; | |
| 53 | + | } | |
| 54 | + | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 55 | + | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |
| 56 | + | old.replaceWith(el); | |
| 57 | + | window.htmx?.process(el); | |
| 58 | + | } | |
| 59 | + | }, | |
| 60 | + | reload() { | |
| 61 | + | if (reloading) return; | |
| 62 | + | reloading = true; | |
| 63 | + | setTimeout(() => location.reload(), 300); | |
| 64 | + | }, | |
| 65 | + | }; | |
| 66 | + | })(); | |
| @@ -30,3 +30,37 @@ | |||||
| 30 | // PageUp/PageDown and space scroll the content without a click first. | 30 | // PageUp/PageDown and space scroll the content without a click first. | ||
| 31 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | 31 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | ||
| 32 | })(); | 32 | })(); | ||
| 33 | + | ||||
| 34 | + | // ermiteLive applies the fragments of the event stream (/-/events, see | |||
| 35 | + | // internal/adapters/web/events.go). A fragment is a top-level element with an | |||
| 36 | + | // id: it replaces the element of the page that has the same id. A fragment | |||
| 37 | + | // marked data-gone removes it; a row of a repository the home page does not | |||
| 38 | + | // list yet is added at the top of the list. A gap, which says the page may | |||
| 39 | + | // have missed events or that what it shows changed, reloads the page. | |||
| 40 | + | window.ermiteLive = (() => { | |||
| 41 | + | let reloading = false; | |||
| 42 | + | return { | |||
| 43 | + | swap(html) { | |||
| 44 | + | const t = document.createElement('template'); | |||
| 45 | + | t.innerHTML = html; | |||
| 46 | + | for (const el of t.content.children) { | |||
| 47 | + | const old = el.id && document.getElementById(el.id); | |||
| 48 | + | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | |||
| 49 | + | if (!old) { | |||
| 50 | + | const rows = document.getElementById('repo-rows'); | |||
| 51 | + | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | |||
| 52 | + | continue; | |||
| 53 | + | } | |||
| 54 | + | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |||
| 55 | + | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |||
| 56 | + | old.replaceWith(el); | |||
| 57 | + | window.htmx?.process(el); | |||
| 58 | + | } | |||
| 59 | + | }, | |||
| 60 | + | reload() { | |||
| 61 | + | if (reloading) return; | |||
| 62 | + | reloading = true; | |||
| 63 | + | setTimeout(() => location.reload(), 300); | |||
| 64 | + | }, | |||
| 65 | + | }; | |||
| 66 | + | })(); | |||
M internal/adapters/web/summary.go +13 -6
| @@ -78,6 +78,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 78 | 78 | ||
| 79 | 79 | l := s.repoLayout(ctx, d, "summary") | |
| 80 | 80 | l.Title = d.name() + " · " + s.instance | |
| 81 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | |
| 81 | 82 | l.Status = Status{ | |
| 82 | 83 | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |
| 83 | 84 | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | |
| @@ -92,13 +93,8 @@ const maxTagDates = 20 | |||
| 92 | 93 | ||
| 93 | 94 | // facts are the key/value rows of the rail. | |
| 94 | 95 | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |
| 95 | - | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |
| 96 | - | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |
| 97 | - | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |
| 98 | - | last.Time = rfc3339(t.AsTime()) | |
| 99 | - | } | |
| 100 | 96 | fs := []Fact{ | |
| 101 | - | last, | |
| 97 | + | lastPushFact(d.repo, now), | |
| 102 | 98 | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 103 | 99 | } | |
| 104 | 100 | if n := len(d.tags); n > 0 { | |
| @@ -116,6 +112,17 @@ func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |||
| 116 | 112 | return fs | |
| 117 | 113 | } | |
| 118 | 114 | ||
| 115 | + | // lastPushFact is the "last push" fact of a repository; the live updates | |
| 116 | + | // render it again. | |
| 117 | + | func lastPushFact(r *pb.Repo, now time.Time) Fact { | |
| 118 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()} | |
| 119 | + | if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |
| 120 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |
| 121 | + | last.Time = rfc3339(t.AsTime()) | |
| 122 | + | } | |
| 123 | + | return last | |
| 124 | + | } | |
| 125 | + | ||
| 119 | 126 | // latestTag is the tag whose commit is the newest. | |
| 120 | 127 | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 121 | 128 | var name string | |
| @@ -78,6 +78,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 78 | 78 | ||||
| 79 | l := s.repoLayout(ctx, d, "summary") | 79 | l := s.repoLayout(ctx, d, "summary") | ||
| 80 | l.Title = d.name() + " · " + s.instance | 80 | l.Title = d.name() + " · " + s.instance | ||
| 81 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | |||
| 81 | l.Status = Status{ | 82 | l.Status = Status{ | ||
| 82 | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | 83 | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | ||
| 83 | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | 84 | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | ||
| @@ -92,13 +93,8 @@ const maxTagDates = 20 | |||||
| 92 | 93 | ||||
| 93 | // facts are the key/value rows of the rail. | 94 | // facts are the key/value rows of the rail. | ||
| 94 | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | 95 | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | ||
| 95 | - | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |||
| 96 | - | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |||
| 97 | - | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |||
| 98 | - | last.Time = rfc3339(t.AsTime()) | |||
| 99 | - | } | |||
| 100 | fs := []Fact{ | 96 | fs := []Fact{ | ||
| 101 | - | last, | 97 | + | lastPushFact(d.repo, now), |
| 102 | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | 98 | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | ||
| 103 | } | 99 | } | ||
| 104 | if n := len(d.tags); n > 0 { | 100 | if n := len(d.tags); n > 0 { | ||
| @@ -116,6 +112,17 @@ func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |||||
| 116 | return fs | 112 | return fs | ||
| 117 | } | 113 | } | ||
| 118 | 114 | ||||
| 115 | + | // lastPushFact is the "last push" fact of a repository; the live updates | |||
| 116 | + | // render it again. | |||
| 117 | + | func lastPushFact(r *pb.Repo, now time.Time) Fact { | |||
| 118 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()} | |||
| 119 | + | if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |||
| 120 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |||
| 121 | + | last.Time = rfc3339(t.AsTime()) | |||
| 122 | + | } | |||
| 123 | + | return last | |||
| 124 | + | } | |||
| 125 | + | ||||
| 119 | // latestTag is the tag whose commit is the newest. | 126 | // latestTag is the tag whose commit is the newest. | ||
| 120 | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | 127 | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | ||
| 121 | var name string | 128 | var name string | ||
M internal/adapters/web/view.go +4 -0
| @@ -65,6 +65,8 @@ type Layout struct { | |||
| 65 | 65 | Read bool // prose page: content capped at the reading measure | |
| 66 | 66 | // Live connects the status bar to the event stream (/-/events). | |
| 67 | 67 | Live bool | |
| 68 | + | // LiveURL is the stream the page follows, with its query. | |
| 69 | + | LiveURL string | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | 72 | // Link is a plain label + href pair. | |
| @@ -81,6 +83,8 @@ type HomeRow struct { | |||
| 81 | 83 | Branch string | |
| 82 | 84 | Tag string | |
| 83 | 85 | Cursor bool | |
| 86 | + | // Fresh marks a row that a push just updated (live updates only). | |
| 87 | + | Fresh bool | |
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | // SortCol is a column header of the home list; it links to its sort order. | |
| @@ -65,6 +65,8 @@ type Layout struct { | |||||
| 65 | Read bool // prose page: content capped at the reading measure | 65 | Read bool // prose page: content capped at the reading measure | ||
| 66 | // Live connects the status bar to the event stream (/-/events). | 66 | // Live connects the status bar to the event stream (/-/events). | ||
| 67 | Live bool | 67 | Live bool | ||
| 68 | + | // LiveURL is the stream the page follows, with its query. | |||
| 69 | + | LiveURL string | |||
| 68 | } | 70 | } | ||
| 69 | 71 | ||||
| 70 | // Link is a plain label + href pair. | 72 | // Link is a plain label + href pair. | ||
| @@ -81,6 +83,8 @@ type HomeRow struct { | |||||
| 81 | Branch string | 83 | Branch string | ||
| 82 | Tag string | 84 | Tag string | ||
| 83 | Cursor bool | 85 | Cursor bool | ||
| 86 | + | // Fresh marks a row that a push just updated (live updates only). | |||
| 87 | + | Fresh bool | |||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | // SortCol is a column header of the home list; it links to its sort order. | 90 | // SortCol is a column header of the home list; it links to its sort order. | ||
M internal/adapters/web/web.go +7 -0
| @@ -47,6 +47,12 @@ type Config struct { | |||
| 47 | 47 | // Repos and Git are the API clients the pages are built from. | |
| 48 | 48 | Repos ermiteconnect.RepoServiceClient | |
| 49 | 49 | Git ermiteconnect.GitServiceClient | |
| 50 | + | // Events is the client of the event stream behind /-/events. Without | |
| 51 | + | // it the pages are not live and the route answers 404. | |
| 52 | + | Events ermiteconnect.EventServiceClient | |
| 53 | + | // Heartbeat is how often an idle event stream gets a comment line; | |
| 54 | + | // DefaultHeartbeat when zero. | |
| 55 | + | Heartbeat time.Duration | |
| 50 | 56 | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 51 | 57 | // the clone URLs are built on it, and its host is the instance name. | |
| 52 | 58 | PublicURL string | |
| @@ -94,6 +100,7 @@ func New(cfg Config) (*Server, error) { | |||
| 94 | 100 | func (s *Server) routes() *http.ServeMux { | |
| 95 | 101 | mux := http.NewServeMux() | |
| 96 | 102 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 103 | + | mux.HandleFunc("GET /-/events", s.events) | |
| 97 | 104 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 98 | 105 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 99 | 106 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -47,6 +47,12 @@ type Config struct { | |||||
| 47 | // Repos and Git are the API clients the pages are built from. | 47 | // Repos and Git are the API clients the pages are built from. | ||
| 48 | Repos ermiteconnect.RepoServiceClient | 48 | Repos ermiteconnect.RepoServiceClient | ||
| 49 | Git ermiteconnect.GitServiceClient | 49 | Git ermiteconnect.GitServiceClient | ||
| 50 | + | // Events is the client of the event stream behind /-/events. Without | |||
| 51 | + | // it the pages are not live and the route answers 404. | |||
| 52 | + | Events ermiteconnect.EventServiceClient | |||
| 53 | + | // Heartbeat is how often an idle event stream gets a comment line; | |||
| 54 | + | // DefaultHeartbeat when zero. | |||
| 55 | + | Heartbeat time.Duration | |||
| 50 | // PublicURL is the base URL of the instance, without a trailing slash: | 56 | // PublicURL is the base URL of the instance, without a trailing slash: | ||
| 51 | // the clone URLs are built on it, and its host is the instance name. | 57 | // the clone URLs are built on it, and its host is the instance name. | ||
| 52 | PublicURL string | 58 | PublicURL string | ||
| @@ -94,6 +100,7 @@ func New(cfg Config) (*Server, error) { | |||||
| 94 | func (s *Server) routes() *http.ServeMux { | 100 | func (s *Server) routes() *http.ServeMux { | ||
| 95 | mux := http.NewServeMux() | 101 | mux := http.NewServeMux() | ||
| 96 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | 102 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | ||
| 103 | + | mux.HandleFunc("GET /-/events", s.events) | |||
| 97 | mux.HandleFunc("GET /{$}", s.page(s.home)) | 104 | mux.HandleFunc("GET /{$}", s.page(s.home)) | ||
| 98 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | 105 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | ||
| 99 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | 106 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | ||