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 };
}
|