oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
32const 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.
54const 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.
66export 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
73export 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