| 1 | // Copied verbatim from Docs (oficina/docs/fixtures/live-updates.mjs), the first |
| 2 | // implementation of the live-sync client; Slides is the second, and the |
| 3 | // extraction to a shared module is slice 4. Only the header name differs: |
| 4 | // Docs-Seq is Slides-Seq here. |
| 5 | // fixtures/live-updates.mjs — the editor's live-update subscriber, |
| 6 | // factored out of fixtures/editor-entry.js so its cursor arithmetic |
| 7 | // and connection policy can be exercised under plain Node with no |
| 8 | // DOM, no EventSource and no real Y.Doc (fixtures/ |
| 9 | // live-updates.test.mjs). Same shape as save-queue.mjs and |
| 10 | // resync-controller.mjs, and for the same reason: the control flow |
| 11 | // being tested does not depend on any of those being real. |
| 12 | // |
| 13 | // What it does: an SSE stream tells this tab that the document has |
| 14 | // content through some seq; this module fetches whatever is newer |
| 15 | // than its own cursor through the ordinary binary updates endpoint |
| 16 | // and hands the bytes to its caller. The stream carries no document |
| 17 | // bytes (see internal/docs/stream.go's own doc comment for why), so |
| 18 | // everything here is cursor bookkeeping and connection policy. |
| 19 | // |
| 20 | // It deliberately owns no DOM. The caller wires visibility and |
| 21 | // activity events to noteHidden/noteActivity, which keeps this file |
| 22 | // testable and keeps every listener registration in one place in |
| 23 | // editor-entry.js. |
| 24 | |
| 25 | // IDLE_MS is how long a visible tab with nothing happening keeps its |
| 26 | // stream open. Platform hibernation is default-on and this app holds |
| 27 | // one connection per open editor, so a document left open on Friday |
| 28 | // must not pin its instance awake all weekend (the design spec's §6, |
| 29 | // and the docs spec's §7.3). Two minutes is well under the server's |
| 30 | // own five-minute TTL, so an idle tab closes on its own terms rather |
| 31 | // than waiting to be cut off. |
| 32 | const IDLE_MS = 120000 |
| 33 | |
| 34 | // HIDDEN_MS is the same thing for a tab that has been switched away |
| 35 | // from, and it exists because closing on `hidden` immediately — which |
| 36 | // is what this module did first — is what a person sees as a flash. |
| 37 | // |
| 38 | // The sequence, measured in a real browser rather than reasoned about: |
| 39 | // tab away, the stream closes; tab back, and the browser paints the |
| 40 | // document THEN, from what is already in the DOM, which is the version |
| 41 | // from before you left. The reconnect and its catch-up fetch land 18ms |
| 42 | // later on a loopback address, and the paragraph visibly rewrites |
| 43 | // itself. Every tab switch cost one frame of stale text, and no amount |
| 44 | // of making the reconnect faster removes it, because the paint happens |
| 45 | // before any round trip can possibly return. |
| 46 | // |
| 47 | // So the fix is not to reconnect faster but not to have disconnected. |
| 48 | // Thirty seconds covers switching between two tabs, which is the whole |
| 49 | // of the case that flashed, and it is still four times more aggressive |
| 50 | // than the visible-idle close: a document left open on Friday drops its |
| 51 | // stream half a minute after you look away, which serves §7.3's |
| 52 | // hibernation requirement strictly better than the 120s a merely idle |
| 53 | // visible tab is already allowed. |
| 54 | const HIDDEN_MS = 30000 |
| 55 | |
| 56 | // updatesURL is the one place either query string this module (or its |
| 57 | // caller in editor-entry.js) can ask for gets spelled, so the spelling |
| 58 | // is pinned by a plain-Node test instead of living twice, inline, in a |
| 59 | // DOM-bound file this project has no test harness for. handlers.go |
| 60 | // matches the raw flag as the literal string "1" and nothing else — |
| 61 | // `raw=true`, `raw=on`, or a bare `raw` all fail silently, with no |
| 62 | // error and no visible symptom beyond the resync controller refetching |
| 63 | // the same corrupt snapshot forever — so this is deliberately a pure |
| 64 | // string builder with no fetch and no DOM, testable by asserting exact |
| 65 | // output rather than by loading a browser. |
| 66 | export function updatesURL(base, { since, raw } = {}) { |
| 67 | const params = [] |
| 68 | if (since !== undefined) params.push(`since=${since}`) |
| 69 | if (raw) params.push('raw=1') |
| 70 | return params.length ? `${base}?${params.join('&')}` : base |
| 71 | } |
| 72 | |
| 73 | export function createLiveUpdates({ |
| 74 | initialSeq = 0, |
| 75 | // openStream(onEvent, onError) -> { close() }. onEvent receives the |
| 76 | // seq an "update" event carried. |
| 77 | openStream, |
| 78 | // fetchSince(since, { raw }) -> { bytes, seq }. bytes may be empty; |
| 79 | // seq is what the server's Slides-Seq header said. raw, when passed |
| 80 | // as { raw: true }, is the decode-failure escape hatch: it must |
| 81 | // reach the server as the literal query string `raw=1` (handlers.go |
| 82 | // matches nothing else), and asks for exactly the doc_updates rows |
| 83 | // after `since` with the snapshot lookup skipped entirely — see the |
| 84 | // comment on the retry in drain() below for why this exists. |
| 85 | fetchSince, |
| 86 | onUpdates, |
| 87 | onError = () => {}, |
| 88 | setTimeoutFn = setTimeout, |
| 89 | clearTimeoutFn = clearTimeout, |
| 90 | idleMs = IDLE_MS, |
| 91 | hiddenMs = HIDDEN_MS, |
| 92 | }) { |
| 93 | let cursor = initialSeq |
| 94 | let stream = null |
| 95 | let idleTimer = null |
| 96 | let stopped = false |
| 97 | |
| 98 | // fetching/pending are the whole of the serialization: one request |
| 99 | // in flight at a time, and a single bit remembering that something |
| 100 | // arrived while it was. Without this a burst of notifications |
| 101 | // starts a fetch per event, and their responses race each other |
| 102 | // into the Y.Doc. |
| 103 | let fetching = false |
| 104 | let pending = false |
| 105 | |
| 106 | // One timer, two deadlines. A hidden tab and an idle visible one are |
| 107 | // closed by the same mechanism and differ only in how long they get, |
| 108 | // so whichever is armed last wins and there is no way for the two to |
| 109 | // be pending at once and race each other. |
| 110 | function armClose(ms) { |
| 111 | if (idleTimer !== null) clearTimeoutFn(idleTimer) |
| 112 | idleTimer = setTimeoutFn(() => { |
| 113 | idleTimer = null |
| 114 | closeStream() |
| 115 | }, ms) |
| 116 | } |
| 117 | |
| 118 | function armIdle() { |
| 119 | armClose(idleMs) |
| 120 | } |
| 121 | |
| 122 | function closeStream() { |
| 123 | if (idleTimer !== null) { |
| 124 | clearTimeoutFn(idleTimer) |
| 125 | idleTimer = null |
| 126 | } |
| 127 | if (!stream) return |
| 128 | stream.close() |
| 129 | stream = null |
| 130 | } |
| 131 | |
| 132 | // openIfClosed is the ONLY place a connection is opened, and every |
| 133 | // close above is explicit. Native EventSource reconnection is never |
| 134 | // relied on anywhere in this module, and that is load-bearing |
| 135 | // rather than fastidious: the browser retries on any server close, |
| 136 | // including the idle close this module just chose and the server's |
| 137 | // own TTL, so a parked tab would reopen its stream forever and go |
| 138 | // on holding a hibernating instance awake. rastrillo's own SSE |
| 139 | // handler (jobs/events.go) does rely on native reconnection, which |
| 140 | // is right for it — it has no close policy of its own — and is the |
| 141 | // one piece of that recipe whose reason does not survive being |
| 142 | // moved here. |
| 143 | function openIfClosed() { |
| 144 | if (stopped || stream) return |
| 145 | stream = openStream( |
| 146 | (seq) => { |
| 147 | armIdle() |
| 148 | // Advisory, never an assignment: this says "there is |
| 149 | // something at least this new", and the fetch is what |
| 150 | // determines the cursor. A tab that has already seen this |
| 151 | // seq — its own write, echoed back — has nothing to do. |
| 152 | if (seq > cursor) drain() |
| 153 | }, |
| 154 | (err) => { |
| 155 | // A dead stream is a freshness failure, never a save failure: |
| 156 | // POST is a different path and edits are still saving, so |
| 157 | // this must not reach the save indicator. The connection is |
| 158 | // dropped rather than retried on a timer, and the next |
| 159 | // activity brings it back. |
| 160 | closeStream() |
| 161 | onError(err) |
| 162 | }, |
| 163 | ) |
| 164 | armIdle() |
| 165 | } |
| 166 | |
| 167 | async function drain() { |
| 168 | if (fetching) { |
| 169 | pending = true |
| 170 | return |
| 171 | } |
| 172 | fetching = true |
| 173 | try { |
| 174 | const from = cursor |
| 175 | let res = await fetchSince(from) |
| 176 | if (stopped) return |
| 177 | try { |
| 178 | if (res && res.bytes && res.bytes.length) onUpdates(res.bytes) |
| 179 | } catch (decodeErr) { |
| 180 | // onUpdates threw: Y.readUpdate hit bytes it could not decode. |
| 181 | // The server serves a snapshot without ever calling into the |
| 182 | // CRDT core (handlers.go's own comment on raw=1), so it cannot |
| 183 | // detect a snapshot corrupted on disk after it was written and |
| 184 | // verified — the browser, via this exact throw, is the only |
| 185 | // reader that can. Without this retry the resync controller |
| 186 | // would refetch the same corrupt bytes forever and the |
| 187 | // document would never load. The retry asks for the SAME |
| 188 | // range (`from`, unchanged) with the snapshot lookup skipped |
| 189 | // entirely, which is the one thing the server has always been |
| 190 | // able to serve correctly. A second throw here — from the |
| 191 | // retried fetch itself, or from onUpdates on the retried |
| 192 | // bytes — is not caught a second time; it falls through to |
| 193 | // the outer catch below, which reports it and leaves the |
| 194 | // cursor untouched. |
| 195 | res = await fetchSince(from, { raw: true }) |
| 196 | if (stopped) return |
| 197 | if (res && res.bytes && res.bytes.length) onUpdates(res.bytes) |
| 198 | } |
| 199 | // max, never assignment: a response that overtakes another must |
| 200 | // not drag the cursor backwards and cause everything above it |
| 201 | // to be fetched and applied a second time. |
| 202 | if (res && typeof res.seq === 'number' && res.seq > cursor) cursor = res.seq |
| 203 | } catch (err) { |
| 204 | // Leave the cursor where it is. Either the bytes were never |
| 205 | // received, or they were received twice (plain, then raw) and |
| 206 | // neither applied — either way anything else would skip them, |
| 207 | // so the next notification (or the connect-time event on the |
| 208 | // next reopen) retries from the same place. |
| 209 | onError(err) |
| 210 | } finally { |
| 211 | fetching = false |
| 212 | if (pending && !stopped) { |
| 213 | pending = false |
| 214 | drain() |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | return { |
| 220 | start() { |
| 221 | stopped = false |
| 222 | openIfClosed() |
| 223 | }, |
| 224 | |
| 225 | // stop is called on teardown and on every resync — the Y.Doc this |
| 226 | // was feeding is about to be discarded, so a response still in |
| 227 | // flight must not be applied to its replacement. |
| 228 | stop() { |
| 229 | stopped = true |
| 230 | closeStream() |
| 231 | }, |
| 232 | |
| 233 | // noteOwnSeq records the seq the server assigned to this tab's own |
| 234 | // POST, and is where the round trip is saved in the common case. |
| 235 | // |
| 236 | // The guard is the whole of it: adopt the number only when it is |
| 237 | // exactly the next row. If this tab is at 10 and its own write |
| 238 | // landed at 43, then rows 11-42 came from somewhere else and have |
| 239 | // not been applied here — taking 43 would skip them permanently, |
| 240 | // with no later notification able to bring them back, because |
| 241 | // every future fetch would ask for what came after 43. Staying at |
| 242 | // 10 costs one fetch and loses nothing. |
| 243 | noteOwnSeq(seq) { |
| 244 | if (typeof seq !== 'number' || !Number.isFinite(seq)) return |
| 245 | if (seq === cursor + 1) cursor = seq |
| 246 | }, |
| 247 | |
| 248 | // noteActivity is any sign of a person: a pointer moving, a |
| 249 | // scroll, a key going down, a tab becoming visible, a local edit. |
| 250 | // It both defers the idle close and brings a closed stream back. |
| 251 | // |
| 252 | // "Any activity" is deliberately broad. Visibility, focus and |
| 253 | // local edits alone leave the commonest case unhandled — someone |
| 254 | // READING a long document on a tab that is already visible and |
| 255 | // already focused fires none of them, so the stream would close |
| 256 | // on idle and nothing would ever reopen it. That tab is exactly |
| 257 | // the watching half of "type in one tab, watch the other", and it |
| 258 | // would have gone quietly stale forever. |
| 259 | noteActivity() { |
| 260 | if (stopped) return |
| 261 | if (stream) armIdle() |
| 262 | else openIfClosed() |
| 263 | }, |
| 264 | |
| 265 | // noteHidden is the tab going into the background. It shortens the |
| 266 | // deadline rather than closing, so a switch away and back inside |
| 267 | // the grace never drops the stream and the tab is already current |
| 268 | // when it is painted — see HIDDEN_MS for what the immediate close |
| 269 | // looked like to a person. |
| 270 | // |
| 271 | // Nothing is opened here: a hidden tab with a closed stream stays |
| 272 | // closed, because coming back is activity and noteActivity is what |
| 273 | // reopens it. |
| 274 | noteHidden() { |
| 275 | if (stopped || !stream) return |
| 276 | armClose(hiddenMs) |
| 277 | }, |
| 278 | |
| 279 | get cursor() { return cursor }, |
| 280 | get streaming() { return stream !== null }, |
| 281 | } |
| 282 | } |
| 283 | |