oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1/* Live sync: the stream, and when to hold one.
2 *
3 * The connection is opened when the tab is visible and closed when it
4 * is not. That is the whole idle policy on this side, and it is
5 * stronger than a heartbeat that stops: a hidden tab holds no socket
6 * at all rather than an idle one. A tab left open all weekend costs the
7 * instance nothing after the moment it was last looked at.
8 *
9 * EventSource reconnects by itself after any close, which is exactly
10 * what must NOT happen when the server says it is done with us. Hence
11 * the explicit close() on "bye" — without it the server's idle timeout
12 * is decoration, because the browser comes straight back.
13 */
14(function () {
15 'use strict';
16
17 /* reopenDelay backs off after a connection we did not close
18 * ourselves. EventSource has its own retry, but a server that is
19 * down answers instantly, so its retry can become a tight loop
20 * against a restarting instance. */
21 var reopenDelay = 3000;
22
23 /* maxSessionMs is the client's own ceiling, matching the server's
24 * maxStreamLife. Both exist because either side may be the one still
25 * running: a browser left open for days should stop asking, and an
26 * instance should not be held awake by one that does not. */
27 var maxSessionMs = 4 * 60 * 60 * 1000;
28
29 var es = null;
30 var openedAt = 0;
31 var stopped = false;
32 var timer = null;
33 var cfg = null;
34
35 function indicator(state, text) {
36 var el = document.querySelector('[data-sheet-live]');
37 if (!el) return;
38 el.dataset.state = state;
39 el.textContent = text;
40 }
41
42 function close() {
43 if (timer) { clearTimeout(timer); timer = null; }
44 if (es) { es.close(); es = null; }
45 }
46
47 function open() {
48 if (es || stopped || !cfg) return;
49 if (document.hidden) return;
50
51 var url = '/api/sheets/' + cfg.sheetId + '/tabs/' + cfg.tabId + '/stream';
52 try {
53 es = new EventSource(url, { withCredentials: true });
54 } catch (err) {
55 indicator('off', 'Live updates unavailable');
56 return;
57 }
58 openedAt = Date.now();
59
60 es.onopen = function () { indicator('on', 'Live'); };
61
62 es.onerror = function () {
63 /* Either the server went away or the network did. EventSource
64 * will retry on its own; the indicator is what tells the person
65 * their grid may be stale in the meantime. */
66 indicator('off', 'Reconnecting');
67 };
68
69 es.addEventListener('cells', function (ev) {
70 var payload;
71 try { payload = JSON.parse(ev.data); } catch (err) { return; }
72 /* Skip our own edit coming back. We painted it from the POST
73 * response already, and repainting it would overwrite the cell
74 * somebody may still be typing in. */
75 if (payload.client && payload.client === cfg.clientId) return;
76 cfg.apply(payload);
77 });
78
79 es.addEventListener('resync', function () {
80 /* The server dropped at least one message for this connection,
81 * so the grid may have holes nothing later fills. Refetch the
82 * whole window and start again. */
83 close();
84 cfg.refetch();
85 schedule(reopenDelay);
86 });
87
88 es.addEventListener('bye', function () {
89 /* The server is finished with this connection — idle, or past
90 * its life ceiling. Close explicitly so the browser's own retry
91 * does not immediately undo it, then come back only when there
92 * is a reason to: the next time this tab becomes visible. */
93 close();
94 indicator('off', 'Paused');
95 });
96
97 schedule(maxSessionMs);
98 }
99
100 /* schedule arranges the next thing to happen to this connection: a
101 * reopen after a drop, or our own ceiling closing it. */
102 function schedule(ms) {
103 if (timer) clearTimeout(timer);
104 timer = setTimeout(function () {
105 timer = null;
106 if (stopped) return;
107 if (es && Date.now() - openedAt >= maxSessionMs) {
108 close();
109 indicator('off', 'Paused');
110 return;
111 }
112 open();
113 }, ms);
114 }
115
116 document.addEventListener('visibilitychange', function () {
117 if (document.hidden) {
118 close();
119 indicator('off', 'Paused');
120 } else {
121 /* Coming back may mean minutes or days have passed, so refetch
122 * before reopening rather than trusting the stream to carry
123 * every edit that happened while nobody was watching. It does
124 * not: the stream has no backlog. */
125 cfg.refetch();
126 open();
127 }
128 });
129
130 window.addEventListener('pagehide', function () {
131 stopped = true;
132 close();
133 });
134
135 window.SheetsLive = {
136 start: function (sheetId, tabId, clientId, apply, refetch) {
137 cfg = {
138 sheetId: sheetId, tabId: tabId, clientId: clientId,
139 apply: apply, refetch: refetch
140 };
141 stopped = false;
142 open();
143 }
144 };
145}());
146