// fixtures/live-updates.mjs — the editor's live-update subscriber, // factored out of fixtures/editor-entry.js so its cursor arithmetic // and connection policy can be exercised under plain Node with no // DOM, no EventSource and no real Y.Doc (fixtures/ // live-updates.test.mjs). Same shape as save-queue.mjs and // resync-controller.mjs, and for the same reason: the control flow // being tested does not depend on any of those being real. // // What it does: an SSE stream tells this tab that the document has // content through some seq; this module fetches whatever is newer // than its own cursor through the ordinary binary updates endpoint // and hands the bytes to its caller. The stream carries no document // bytes (see internal/docs/stream.go's own doc comment for why), so // everything here is cursor bookkeeping and connection policy. // // It deliberately owns no DOM. The caller wires visibility and // activity events to noteHidden/noteActivity, which keeps this file // testable and keeps every listener registration in one place in // editor-entry.js. // IDLE_MS is how long a visible tab with nothing happening keeps its // stream open. Platform hibernation is default-on and this app holds // one connection per open editor, so a document left open on Friday // must not pin its instance awake all weekend (the design spec's §6, // and the docs spec's §7.3). Two minutes is well under the server's // own five-minute TTL, so an idle tab closes on its own terms rather // than waiting to be cut off. const IDLE_MS = 120000 // HIDDEN_MS is the same thing for a tab that has been switched away // from, and it exists because closing on `hidden` immediately — which // is what this module did first — is what a person sees as a flash. // // The sequence, measured in a real browser rather than reasoned about: // tab away, the stream closes; tab back, and the browser paints the // document THEN, from what is already in the DOM, which is the version // from before you left. The reconnect and its catch-up fetch land 18ms // later on a loopback address, and the paragraph visibly rewrites // itself. Every tab switch cost one frame of stale text, and no amount // of making the reconnect faster removes it, because the paint happens // before any round trip can possibly return. // // So the fix is not to reconnect faster but not to have disconnected. // Thirty seconds covers switching between two tabs, which is the whole // of the case that flashed, and it is still four times more aggressive // than the visible-idle close: a document left open on Friday drops its // stream half a minute after you look away, which serves §7.3's // hibernation requirement strictly better than the 120s a merely idle // visible tab is already allowed. const HIDDEN_MS = 30000 // updatesURL is the one place either query string this module (or its // caller in editor-entry.js) can ask for gets spelled, so the spelling // is pinned by a plain-Node test instead of living twice, inline, in a // DOM-bound file this project has no test harness for. handlers.go // matches the raw flag as the literal string "1" and nothing else — // `raw=true`, `raw=on`, or a bare `raw` all fail silently, with no // error and no visible symptom beyond the resync controller refetching // the same corrupt snapshot forever — so this is deliberately a pure // string builder with no fetch and no DOM, testable by asserting exact // output rather than by loading a browser. export function updatesURL(base, { since, raw } = {}) { const params = [] if (since !== undefined) params.push(`since=${since}`) if (raw) params.push('raw=1') return params.length ? `${base}?${params.join('&')}` : base } export function createLiveUpdates({ initialSeq = 0, // openStream(onEvent, onError) -> { close() }. onEvent receives the // seq an "update" event carried. openStream, // fetchSince(since, { raw }) -> { bytes, seq }. bytes may be empty; // seq is what the server's Docs-Seq header said. raw, when passed // as { raw: true }, is the decode-failure escape hatch: it must // reach the server as the literal query string `raw=1` (handlers.go // matches nothing else), and asks for exactly the doc_updates rows // after `since` with the snapshot lookup skipped entirely — see the // comment on the retry in drain() below for why this exists. fetchSince, onUpdates, onError = () => {}, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, idleMs = IDLE_MS, hiddenMs = HIDDEN_MS, }) { let cursor = initialSeq let stream = null let idleTimer = null let stopped = false // fetching/pending are the whole of the serialization: one request // in flight at a time, and a single bit remembering that something // arrived while it was. Without this a burst of notifications // starts a fetch per event, and their responses race each other // into the Y.Doc. let fetching = false let pending = false // One timer, two deadlines. A hidden tab and an idle visible one are // closed by the same mechanism and differ only in how long they get, // so whichever is armed last wins and there is no way for the two to // be pending at once and race each other. function armClose(ms) { if (idleTimer !== null) clearTimeoutFn(idleTimer) idleTimer = setTimeoutFn(() => { idleTimer = null closeStream() }, ms) } function armIdle() { armClose(idleMs) } function closeStream() { if (idleTimer !== null) { clearTimeoutFn(idleTimer) idleTimer = null } if (!stream) return stream.close() stream = null } // openIfClosed is the ONLY place a connection is opened, and every // close above is explicit. Native EventSource reconnection is never // relied on anywhere in this module, and that is load-bearing // rather than fastidious: the browser retries on any server close, // including the idle close this module just chose and the server's // own TTL, so a parked tab would reopen its stream forever and go // on holding a hibernating instance awake. rastrillo's own SSE // handler (jobs/events.go) does rely on native reconnection, which // is right for it — it has no close policy of its own — and is the // one piece of that recipe whose reason does not survive being // moved here. function openIfClosed() { if (stopped || stream) return stream = openStream( (seq) => { armIdle() // Advisory, never an assignment: this says "there is // something at least this new", and the fetch is what // determines the cursor. A tab that has already seen this // seq — its own write, echoed back — has nothing to do. if (seq > cursor) drain() }, (err) => { // A dead stream is a freshness failure, never a save failure: // POST is a different path and edits are still saving, so // this must not reach the save indicator. The connection is // dropped rather than retried on a timer, and the next // activity brings it back. closeStream() onError(err) }, ) armIdle() } async function drain() { if (fetching) { pending = true return } fetching = true try { const from = cursor let res = await fetchSince(from) if (stopped) return try { if (res && res.bytes && res.bytes.length) onUpdates(res.bytes) } catch (decodeErr) { // onUpdates threw: Y.readUpdate hit bytes it could not decode. // The server serves a snapshot without ever calling into the // CRDT core (handlers.go's own comment on raw=1), so it cannot // detect a snapshot corrupted on disk after it was written and // verified — the browser, via this exact throw, is the only // reader that can. Without this retry the resync controller // would refetch the same corrupt bytes forever and the // document would never load. The retry asks for the SAME // range (`from`, unchanged) with the snapshot lookup skipped // entirely, which is the one thing the server has always been // able to serve correctly. A second throw here — from the // retried fetch itself, or from onUpdates on the retried // bytes — is not caught a second time; it falls through to // the outer catch below, which reports it and leaves the // cursor untouched. res = await fetchSince(from, { raw: true }) if (stopped) return if (res && res.bytes && res.bytes.length) onUpdates(res.bytes) } // max, never assignment: a response that overtakes another must // not drag the cursor backwards and cause everything above it // to be fetched and applied a second time. if (res && typeof res.seq === 'number' && res.seq > cursor) cursor = res.seq } catch (err) { // Leave the cursor where it is. Either the bytes were never // received, or they were received twice (plain, then raw) and // neither applied — either way anything else would skip them, // so the next notification (or the connect-time event on the // next reopen) retries from the same place. onError(err) } finally { fetching = false if (pending && !stopped) { pending = false drain() } } } return { start() { stopped = false openIfClosed() }, // stop is called on teardown and on every resync — the Y.Doc this // was feeding is about to be discarded, so a response still in // flight must not be applied to its replacement. stop() { stopped = true closeStream() }, // noteOwnSeq records the seq the server assigned to this tab's own // POST, and is where the round trip is saved in the common case. // // The guard is the whole of it: adopt the number only when it is // exactly the next row. If this tab is at 10 and its own write // landed at 43, then rows 11-42 came from somewhere else and have // not been applied here — taking 43 would skip them permanently, // with no later notification able to bring them back, because // every future fetch would ask for what came after 43. Staying at // 10 costs one fetch and loses nothing. noteOwnSeq(seq) { if (typeof seq !== 'number' || !Number.isFinite(seq)) return if (seq === cursor + 1) cursor = seq }, // noteActivity is any sign of a person: a pointer moving, a // scroll, a key going down, a tab becoming visible, a local edit. // It both defers the idle close and brings a closed stream back. // // "Any activity" is deliberately broad. Visibility, focus and // local edits alone leave the commonest case unhandled — someone // READING a long document on a tab that is already visible and // already focused fires none of them, so the stream would close // on idle and nothing would ever reopen it. That tab is exactly // the watching half of "type in one tab, watch the other", and it // would have gone quietly stale forever. noteActivity() { if (stopped) return if (stream) armIdle() else openIfClosed() }, // noteHidden is the tab going into the background. It shortens the // deadline rather than closing, so a switch away and back inside // the grace never drops the stream and the tab is already current // when it is painted — see HIDDEN_MS for what the immediate close // looked like to a person. // // Nothing is opened here: a hidden tab with a closed stream stays // closed, because coming back is activity and noteActivity is what // reopens it. noteHidden() { if (stopped || !stream) return armClose(hiddenMs) }, get cursor() { return cursor }, get streaming() { return stream !== null }, } }