File size: 4,663 Bytes
45d6656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
 * Refocus re-sync guard.
 *
 * A long-stale never-closed tab must hear the latest server state BEFORE
 * the user types on top of it - otherwise its old buffer can clobber a
 * collaborator's newer content (the data-loss incident this guards
 * against). On tab refocus we force a sync round-trip and hold the editor
 * read-only until it completes, detecting dead sockets via websocket
 * status / readyState / last-message timestamp (including half-open
 * sockets that never fired a close event, via a post-forceSync probe).
 *
 * Local-first safety net: if the sync can't complete (offline / Space
 * asleep) editing is always released after a grace window - the
 * y-indexeddb copy is durable and merges on the next reconnect, so the
 * user is never trapped in a permanent read-only state.
 *
 * The DOM concern (the `visibilitychange` listener + `visibilityState`
 * gate) stays in the React effect; this module is pure logic so it can
 * be unit-tested with fake timers and no browser.
 */

// WebSocket.OPEN is always 1 per the WHATWG spec. We hardcode it instead
// of referencing the `WebSocket` DOM global so this module runs in a
// plain Node test environment.
const WS_OPEN = 1;

export interface RefocusSocketLike {
  status?: string;
  webSocket?: { readyState?: number } | null;
  lastMessageReceived?: number;
}

export interface RefocusProviderLike {
  synced: boolean;
  connect: () => Promise<unknown>;
  forceSync: () => void;
  on: (event: string, callback: (...args: any[]) => void) => unknown;
  off: (event: string, callback: (...args: any[]) => void) => unknown;
  configuration?: { websocketProvider?: RefocusSocketLike };
}

export interface RefocusEditorLike {
  isEditable: boolean;
  isDestroyed: boolean;
  setEditable: (editable: boolean) => unknown;
}

export interface RefocusSyncOptions {
  provider: RefocusProviderLike;
  editor: RefocusEditorLike;
  /** How long to stay read-only before releasing when offline. */
  graceMs?: number;
  /** How long to wait for a forceSync reply before declaring a half-open socket. */
  probeMs?: number;
}

export interface RefocusSync {
  /** Call when the tab becomes visible again. */
  onVisibility: () => void;
  /** Detach listeners, clear timers, and release any read-only lock. */
  dispose: () => void;
}

export function createRefocusSync({
  provider,
  editor,
  graceMs = 4000,
  probeMs = 2000,
}: RefocusSyncOptions): RefocusSync {
  let graceTimer: ReturnType<typeof setTimeout> | null = null;
  let probeTimer: ReturnType<typeof setTimeout> | null = null;

  const clearTimers = () => {
    if (graceTimer) clearTimeout(graceTimer);
    if (probeTimer) clearTimeout(probeTimer);
    graceTimer = null;
    probeTimer = null;
  };

  const unlock = () => {
    clearTimers();
    if (!editor.isDestroyed && !editor.isEditable) editor.setEditable(true);
  };

  const lock = () => {
    if (!editor.isDestroyed && editor.isEditable) editor.setEditable(false);
    if (graceTimer) clearTimeout(graceTimer);
    // Never trap the user: offline edits stay valid (y-indexeddb) and
    // merge on the next reconnect, so always release after the grace window.
    graceTimer = setTimeout(unlock, graceMs);
  };

  const resync = () => {
    provider.connect().catch(() => {});
    provider.forceSync();
  };

  const socket = () => provider.configuration?.websocketProvider;

  // "Obviously dead" cases we can detect synchronously, without a probe.
  const looksDead = () => {
    const ws = socket();
    if (!ws) return !provider.synced;
    if (ws.status !== "connected") return true;
    if (ws.webSocket && ws.webSocket.readyState !== WS_OPEN) return true;
    return false;
  };

  const onSynced = () => unlock();
  provider.on("synced", onSynced);

  const onVisibility = () => {
    const before = socket()?.lastMessageReceived ?? 0;
    resync();

    // Synchronously-detectable stale state -> lock immediately.
    if (looksDead() || !provider.synced) {
      lock();
      return;
    }

    // Socket *claims* to be alive (connected + OPEN) but a backgrounded
    // tab can hold a half-dead socket that never fired a close event.
    // Our forceSync() must draw a reply; if no new message lands within
    // the probe window, treat it as stale and force a hard reconnect.
    if (probeTimer) clearTimeout(probeTimer);
    probeTimer = setTimeout(() => {
      const advanced = (socket()?.lastMessageReceived ?? 0) > before;
      if (!advanced) {
        resync();
        lock();
      }
    }, probeMs);
  };

  const dispose = () => {
    provider.off("synced", onSynced);
    clearTimers();
    unlock();
  };

  return { onVisibility, dispose };
}