Files
awesome-copilot/extensions/draw/public/sync.js
T

487 lines
20 KiB
JavaScript

// Keeps the editor and the extension's copy of the drawing in sync.
// Local edits are sent as small element-level ops, and the server sends each change as ops too.
// Remote (agent) edits are merged on top of whatever the user has not saved yet.
import { applyOps, diffOps, mergeOps } from "/lib/model.mjs";
// How many changes can wait for the one before them (see drainOps) before the panel gives up on
// them and fetches the whole drawing again.
const MAX_EARLY = 200;
// Browsers only send keepalive requests (the kind that still goes out once the page is gone)
// while their bodies add up to at most 64 KiB (see flushBeacon).
const KEEPALIVE_BYTES = 64 * 1024;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export class Sync {
constructor({ instanceId, token, editor, handlers }) {
this.instanceId = instanceId;
this.token = token;
this.editor = editor;
this.handlers = handlers;
this.clientId = `c${Math.random().toString(36).slice(2, 10)}`;
this.drawingId = null;
this.rev = 0;
this.synced = new Map();
this.inflight = false;
// Every change sent gets the next number, so the server can tell an old one that arrives late.
this.seq = 0;
// The ops of the change on its way, if any, and the drawing they are for.
this.sending = null;
this.timer = 0;
this.deferred = null;
// Changes from the server that came before the one they follow (see drainOps).
this.early = [];
// Set when a change was missed, so the whole drawing has to be fetched again.
this.behind = false;
this.resyncing = false;
// The agent's newest selection, while the editor cannot show it yet (see showSelection).
this.pendingSelection = null;
this.failures = 0;
this.retryTimer = 0;
// Why the server refused our last changes, and why the extension cannot write this drawing
// to disk (it still has the changes in memory). Both show as errors until they clear.
this.refusal = null;
this.diskError = null;
// Set when the server says this drawing was deleted, so there is nothing left to save it to.
this.gone = false;
// Drawings this panel is moving to, each waiting for the edits on the current one to be saved.
this.switching = new Set();
this.source = null;
editor.on("change", () => this.schedule());
editor.on("idle", () => this.onIdle());
window.addEventListener("pagehide", () => this.flushBeacon());
}
url(route, extra = "") {
return `/api/${route}?i=${encodeURIComponent(this.instanceId)}&t=${encodeURIComponent(this.token)}${extra}`;
}
async post(route, body) {
const res = await fetch(this.url(route), {
method: "POST",
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: JSON.stringify({ clientId: this.clientId, ...body }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.error || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return data;
}
async loadState() {
const res = await fetch(this.url("state"), { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Could not load the drawing (${res.status})`);
return data;
}
setDoc(doc) {
this.drawingId = doc.id;
this.rev = doc.rev;
this.synced = new Map(doc.elements.map((e) => [e.id, e]));
this.deferred = null;
this.early = [];
this.behind = false;
this.pendingSelection = null;
this.gone = false;
}
connect() {
const source = new EventSource(this.url("events", `&c=${this.clientId}`));
this.source = source;
const on = (name, fn) => source.addEventListener(name, (ev) => {
let data;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
fn(data);
});
source.addEventListener("open", () => {
this.handlers.connection?.(true);
// "Reconnecting" covered the status line, so bring back an error that still applies.
if (this.refusal) this.handlers.status?.("error", this.refusal);
else if (this.diskError) this.settled();
// Changes made before the server knew about this connection (since the drawing was
// loaded, or while the connection was down) never come as events.
this.resync();
});
source.addEventListener("error", () => this.handlers.connection?.(false));
on("ops", (ev) => this.onOps(ev));
on("list", ({ drawings }) => this.handlers.list?.(drawings));
on("switch", ({ drawing, switchId }) => this.switchTo(drawing, { switchId }));
on("export", (req) => this.handlers.exportRequest?.(req));
on("select", ({ drawingId, ids }) => {
if (drawingId === this.drawingId) this.showSelection(ids);
for (const t of this.switching) if (t.drawing.id === drawingId) t.selection = ids;
});
on("settings", ({ settings, origin }) => {
if (origin !== this.clientId) this.handlers.settings?.(settings);
});
on("save", ({ drawingId, error }) => {
if (drawingId === this.drawingId) this.setDiskError(error);
for (const t of this.switching) if (t.drawing.id === drawingId) t.drawing = { ...t.drawing, saveError: error };
});
}
// Fetches the whole drawing again: each time the events connect, and when a change turns out to
// be missing (see drainOps). Changes that come meanwhile wait in `early`.
async resync() {
if (this.resyncing) {
// This one may have asked before the change that was missed, so ask again after it.
this.behind = true;
return;
}
this.resyncing = true;
this.behind = false;
let state = null;
try {
state = await this.loadState();
} catch {
// The next change or reconnect tries again.
this.behind = true;
} finally {
this.resyncing = false;
}
if (!state) return;
this.handlers.list?.(state.drawings);
if (state.settings) this.handlers.settings?.(state.settings);
if (state.drawing.id !== this.drawingId) {
this.switchTo(state.drawing);
} else {
this.onRemote(state.drawing);
this.setDiskError(state.drawing.saveError);
this.drainOps({ refetch: false });
}
}
// A change from the server: the ops that turn the drawing at `baseRev` into the one at `rev`.
onOps(ev) {
for (const t of this.switching) {
if (t.drawing.id !== ev.drawingId || ev.rev <= t.drawing.rev) continue;
if (ev.baseRev === t.drawing.rev) {
t.drawing = { ...t.drawing, rev: ev.rev, elements: ev.ops ? mergeOps(t.drawing.elements, ev.ops) : t.drawing.elements };
} else {
t.behind = true;
}
}
if (ev.drawingId !== this.drawingId) return;
if (this.early.length >= MAX_EARLY) {
this.early = [];
this.behind = true;
}
this.early.push(ev);
this.drainOps();
}
// Applies the changes that follow on from the newest copy of the drawing this panel has: the one
// waiting to be shown, or else the one it last loaded or saved. While our own save is on its way,
// a change made after it can come first, and waits for its reply (our change is the one it
// follows). Any other gap means a change never came (the panel was on another drawing for a
// moment, say), so the whole drawing is fetched again. Right after that fetch (`refetch` false),
// a change that still does not follow on can never apply, so it is dropped instead, and the next
// change that does not follow on fetches the drawing again.
drainOps({ refetch = true } = {}) {
const queue = this.early;
while (queue.length) {
const ev = queue[0];
const base = this.deferred || { rev: this.rev, elements: null };
if (ev.drawingId !== this.drawingId || ev.rev <= base.rev) {
queue.shift();
continue;
}
if (ev.baseRev !== base.rev) break;
queue.shift();
const elements = base.elements || [...this.synced.values()];
this.onRemote({ id: ev.drawingId, rev: ev.rev, elements: ev.ops ? mergeOps(elements, ev.ops) : elements });
}
if (this.inflight) return;
if (queue.length) {
if (refetch) this.behind = true;
else queue.length = 0;
}
if (this.behind) this.resync();
}
onRemote(drawing) {
if (!drawing) return;
// A drawing this panel is about to show: keep the newest copy of it for when it does.
for (const t of this.switching) if (t.drawing.id === drawing.id && drawing.rev > t.drawing.rev) t.drawing = drawing;
if (drawing.id !== this.drawingId || drawing.rev <= this.rev) return;
if (this.inflight || this.editor.busy) {
if (!this.deferred || drawing.rev > this.deferred.rev) this.deferred = drawing;
return;
}
this.rebase(drawing);
}
// Replaces our base with the remote doc and re-applies unsaved local changes on top. `record`
// is false when the doc differs only because the server tidied up what we sent.
rebase(drawing, { record = true } = {}) {
const pending = diffOps(this.synced, this.editor.elements);
this.synced = new Map(drawing.elements.map((e) => [e.id, e]));
this.rev = drawing.rev;
this.deferred = null;
// No element cap here. Going over it makes the server refuse the save and say why, which is
// better than the user's newest elements quietly disappearing.
const next = pending ? applyOps(drawing.elements, pending, Infinity) : drawing.elements;
this.editor.applyRemote(next, { record });
if (pending) this.schedule();
}
// Shows another drawing. Edits on screen that are not saved yet would be lost, so when they
// cannot be saved first, the panel stays on this drawing, tells the server so, and resolves
// false, and the unsaved handler lets the user discard them. `discard` skips that check.
// A deleted drawing has nowhere left to save to, so it never holds the panel. `switchId` comes
// with a drawing the agent opened, and the server hears which way it went (see openDrawing in
// server.mjs).
async switchTo(drawing, { discard = false, switchId = null } = {}) {
// Showing the drawing that is already on screen loses nothing, so it is only an update to it.
if (drawing.id === this.drawingId) {
this.onRemote(drawing);
this.confirmSwitch(switchId);
return true;
}
// The server already treats the other drawing as shown, so while the flush below waits, it
// sends that drawing's changes, save status and selection. They are kept here until then.
const target = { drawing, selection: null, behind: false };
this.switching.add(target);
let ready;
try {
ready = discard || (await this.flush(true)) || this.gone;
} finally {
this.switching.delete(target);
}
if (!ready) {
// The server has already moved this panel to the other drawing, so move it back. Changes to
// this drawing did not come to the panel in the meantime, so it takes the copy in the reply.
this.post("drawings", { action: "open", id: this.drawingId, switchId })
.then((res) => {
if (res.drawing) this.onRemote(res.drawing);
this.drainOps();
this.sendSelection([...this.editor.selection]);
})
.catch(() => {});
this.handlers.unsaved?.(target.drawing);
return false;
}
drawing = target.drawing;
// Another switch can show this drawing first. Then this copy is only an update to it.
if (drawing.id === this.drawingId) {
this.onRemote(drawing);
this.confirmSwitch(switchId);
return true;
}
this.setDoc(drawing);
this.handlers.switched?.(drawing);
if (target.selection) this.showSelection(target.selection);
// Messages about the last drawing do not apply to this one.
this.refusal = null;
this.diskError = null;
this.setDiskError(drawing.saveError);
if (!this.diskError) this.settled();
// A change to it came while the panel waited, and one before it never did.
if (target.behind) this.resync();
this.confirmSwitch(switchId);
return true;
}
confirmSwitch(switchId) {
if (switchId) this.post("switched", { switchId }).catch(() => {});
}
// The extension has all our changes, so show "saved", unless it cannot write them to disk.
settled() {
if (this.diskError) this.handlers.status?.("error", `Not saved to disk: ${this.diskError}`);
else this.handlers.status?.("saved");
}
setDiskError(error) {
const was = this.diskError;
this.diskError = error || null;
if (this.diskError === was) return;
if (this.diskError) this.handlers.problem?.(`This drawing could not be saved to disk: ${this.diskError}. Draw will keep trying.`);
if (this.diskError || (!this.inflight && !this.dirty)) this.settled();
}
onIdle() {
// Once the handler that ended the edit or drag is done, since it may start the next one right
// away (Tab opens the next step's label). Then what waits keeps waiting.
queueMicrotask(() => this.applyDeferred());
this.schedule();
}
// Shows a selection the agent made. While the user drags or types, or while an agent change
// waits for a save to finish (it may add the elements named here), the newest one waits for
// applyDeferred.
showSelection(ids) {
this.pendingSelection = { ids, before: [...this.editor.selection] };
this.applyDeferred();
}
// Applies what the server sent while the editor could not take it: the newest remote doc, and
// then the agent's newest selection, unless the user picked something else since it came.
// Their newer choice stands then, and the server already has it. Elements that are gone do
// not count as a different choice, since removing an element also drops it from the selection.
applyDeferred() {
if (this.inflight || this.editor.busy) return;
if (this.deferred) {
const d = this.deferred;
this.deferred = null;
if (d.rev > this.rev) this.rebase(d);
}
const sel = this.pendingSelection;
if (!sel) return;
this.pendingSelection = null;
const now = this.editor.selection;
const before = sel.before.filter((id) => this.editor.byId.has(id));
if (before.length === now.size && before.every((id) => now.has(id))) this.handlers.select?.(sel.ids);
}
schedule(delay = 60) {
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = 0;
this.flush();
}, delay);
}
get dirty() {
return !!this.drawingId && !!diffOps(this.synced, this.editor.elements);
}
// Sends the edits the extension does not have yet. Resolves true once it has everything the
// editor shows, false while something is still unsaved. `force` waits for a request that is
// already out and sends even in the middle of a gesture, for callers that need the saved copy
// to match the screen.
async flush(force = false) {
if (this.inflight) {
if (!force) return false;
while (this.inflight) await sleep(30);
}
if (!this.drawingId) return true;
if (!force && this.editor.busy) return false;
const sent = this.editor.elements;
const ops = diffOps(this.synced, sent);
if (!ops) {
this.refusal = null;
this.settled();
return true;
}
this.inflight = true;
this.handlers.status?.("saving");
const drawingId = this.drawingId;
this.sending = { drawingId, ops };
let retry = false;
let refused = false;
try {
const res = await this.post("ops", { drawingId, baseRev: this.rev, seq: ++this.seq, ops });
this.failures = 0;
this.refusal = null;
if (drawingId === this.drawingId) {
// "ignored" means the server already had newer changes from this page, so these were
// not applied. They are still on screen, so rebasing sends them again.
if (!res.ignored) this.synced = new Map(sent.map((e) => [e.id, e]));
this.rev = res.rev;
this.inflight = false;
// "cleaned" means the server saved our changes in a tidier form than we sent (a size
// past the limit, say), so the screen takes that form, without an undo step of its own.
if (res.drawing) this.rebase(res.drawing, { record: !res.cleaned });
}
} catch (err) {
if (err.status === 404) {
refused = true;
this.gone = drawingId === this.drawingId;
this.handlers.status?.("error", "This drawing was deleted.");
} else if (err.status === 400 || err.status === 413) {
// Sending the same changes again would fail the same way, so the next edit tries again
// (undoing, for example, can bring the drawing back under a limit).
refused = true;
const msg = `Not saved: ${err.message}`;
this.handlers.status?.("error", msg);
if (msg !== this.refusal) this.handlers.problem?.(msg);
this.refusal = msg;
} else {
this.failures += 1;
retry = true;
this.handlers.status?.("error", this.failures <= 6 ? "Not saved yet, retrying" : "Could not save. Check that Copilot is running.");
}
} finally {
this.inflight = false;
this.sending = null;
}
// Changes that came before the reply, and waited for it.
this.drainOps();
if (retry) {
clearTimeout(this.retryTimer);
this.retryTimer = setTimeout(() => this.schedule(0), Math.min(8000, 400 * 2 ** this.failures));
return false;
}
if (drawingId !== this.drawingId) {
// The panel moved to another drawing while this was out. Edits made there since then
// could not be sent during that time, so send them now.
if (this.dirty) this.schedule();
return !this.dirty;
}
this.applyDeferred();
if (refused) return false;
if (diffOps(this.synced, this.editor.elements)) {
// Edits made while the request was out. A forced flush sends them now too.
if (force) return this.flush(true);
this.schedule();
return false;
}
this.settled();
return true;
}
// Best effort save when the iframe goes away. A change still on its way can reach the server
// before or after this one. If it comes after, the server drops it (its number is lower). If
// it comes first, this one has to undo whatever the editor no longer shows, so it also sets
// every element that change touched to what the editor shows now.
flushBeacon() {
if (!this.drawingId) return;
const elements = this.editor.elements;
const ops = diffOps(this.synced, elements) || { upserts: [], deletes: [] };
const out = this.sending?.drawingId === this.drawingId ? this.sending.ops : null;
if (out) {
const now = new Map(elements.map((e) => [e.id, e]));
const listed = new Set([...ops.upserts.map((e) => e.id), ...ops.deletes]);
for (const id of [...out.upserts.map((e) => e.id), ...out.deletes]) {
if (listed.has(id)) continue;
listed.add(id);
if (now.has(id)) ops.upserts.push(now.get(id));
else ops.deletes.push(id);
}
// Its order, or a deleted element coming back at the end, can leave the order wrong.
if (out.order || out.deletes.some((id) => now.has(id))) ops.order = elements.map((e) => e.id);
}
if (!ops.upserts.length && !ops.deletes.length && !ops.order) return;
const body = JSON.stringify({ clientId: this.clientId, drawingId: this.drawingId, seq: ++this.seq, ops });
// Only a keepalive request is sure to arrive once the page is gone, and past the keepalive limit
// the browser refuses it outright. Splitting it would not help, since the limit is for all of
// them together. Any one element fits (see MAX_PEN_POINTS), and a change is saved about 60 ms
// after it is made, so only a bulk change to a big drawing that was not saved yet (a drag of
// many shapes that is still going on, say) is bigger. That goes as an ordinary request, which
// can still reach the local server but is often cancelled along with the page.
const keepalive = new TextEncoder().encode(body).length <= KEEPALIVE_BYTES;
try {
fetch(this.url("ops"), { method: "POST", body, keepalive, headers: { "Content-Type": "text/plain;charset=utf-8" } }).catch(() => {});
} catch {
// Nothing else we can do while unloading.
}
}
sendSelection(ids) {
clearTimeout(this.selTimer);
this.selTimer = setTimeout(() => {
this.post("selection", { drawingId: this.drawingId, ids }).catch(() => {});
}, 150);
}
}