Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 788ba0fa01083b300ad50913
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/sync.ts
// Keeps a board in step between everyone who has it open.
// Each element is one document (its Excalidraw JSON). A device sends the elements whose version went
// up, a few times a second at most, and follows only recently changed elements live: the live feed
// re-sends every document it follows on each change, so it follows changes since a recent server time
// and moves that time forward as it grows. Reconciling by version (Excalidraw's own rule) makes the
// order of arrival not matter. Pointers ride a separate in-memory collection; images are stored files.
import { get, getFiles, set, setFile, setMany, subscribe, serverTimestamp } from "@bounded-sh/client";
import { CaptureUpdateAction, reconcileElements, restoreElements } from "@excalidraw/excalidraw";
import type { ExcalidrawElement, FileId, OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type { AppState, BinaryFileData, BinaryFiles, Collaborator, ExcalidrawImperativeAPI, SocketId } from "@excalidraw/excalidraw/types";
import { IMAGE_TYPES, LIMITS, byteLength, isElement } from "../../functions/lib/canvas";
const FLUSH_MS = 120;
const CURSOR_MS = 90;
const FOLLOW_MAX = 150;
const BATCH_ITEMS = 100;
const BATCH_CHARS = 700_000;
// Someone who closes the tab cannot take their pointer off (the browser cancels that last write), so an
// open, visible board sends its pointer again every 20 s, and pointers older than 45 s are hidden.
const IDLE_SECONDS = 45;
const POINTER_REFRESH_MS = 20_000;
const MAX_FAILURES = 8;
interface Row { id: string; json: string; at: number }
interface CursorRow { id: string; x: number; y: number; name: string; tool: string; down: boolean; sel: string; at: number }
export type SaveState = "saved" | "saving" | "offline" | "readonly";
const now = () => Math.floor(Date.now() / 1000);
const parse = (json: string): ExcalidrawElement | null => {
try {
const el = JSON.parse(json);
return isElement(el) ? el : null;
} catch {
return null;
}
};
const isDenied = (err: unknown) => {
const e = err as { status?: number; code?: string; message?: string; isBoundedDecline?: boolean };
return e?.status === 403 || e?.isBoundedDecline === true || /denied|forbidden|403|Policy failed/i.test(String(e?.message || ""));
};
/** Excalidraw's own test for elements too small to see, which it removes instead of deleting. */
const invisible = (el: ExcalidrawElement) =>
!el.isDeleted && (((el.type === "line" || el.type === "arrow" || el.type === "freedraw") && el.points.length < 2) || (el.type !== "text" && el.width === 0 && el.height === 0));
export interface SyncEvents {
onSave: (state: SaveState) => void;
onDenied: () => void;
onNotice: (text: string) => void;
}
export async function loadElements(boardId: string): Promise<{ elements: ExcalidrawElement[]; latest: number }> {
const out: ExcalidrawElement[] = [];
let latest = 0;
let cursor: string | undefined;
do {
const res = (await get(`boards/${boardId}/els`, { limit: 1000, ...(cursor ? { cursor } : {}) })) as { data: Row[]; nextCursor?: string | null };
for (const r of res?.data || []) {
const el = parse(r.json);
if (el) out.push(el);
latest = Math.max(latest, r.at || 0);
}
cursor = res?.nextCursor || undefined;
} while (cursor);
return { elements: out, latest };
}
export class BoardSync {
private api: ExcalidrawImperativeAPI | null = null;
/** The highest version of each element the server is known to have, sent or received. */
private known = new Map<string, number>();
private pending = new Map<string, ExcalidrawElement>();
private tooBig = new Set<string>();
private flushTimer = 0;
private flushing = false;
private failures = 0;
private follow: { stop: (() => Promise<void>) | null; from: number; moving: boolean } = { stop: null, from: 0, moving: false };
private stopCursors: (() => Promise<void>) | null = null;
private cursorTimer = 0;
private cursorNext: { x: number; y: number; tool: string; down: boolean } | null = null;
private cursorSel = "[]";
private cursorRows: CursorRow[] = [];
private skew = 0;
private mineSentAt = 0;
private idleTimer = 0;
private uploaded = new Set<string>();
private uploading = new Set<string>();
private fetching = new Set<string>();
private fetchTries = new Map<string, number>();
private lastFiles = "";
private closed = false;
constructor(private boardId: string, private uid: string, public canEdit: boolean, private name: () => string, private events: SyncEvents) {}
private path(sub: string) { return `boards/${this.boardId}/${sub}`; }
/** Starts following once the editor exists. `latest` is the newest server time among the loaded elements. */
async start(api: ExcalidrawImperativeAPI, loaded: ExcalidrawElement[], latest: number) {
this.api = api;
for (const el of loaded) this.known.set(el.id, el.version);
await this.followFrom(Math.max(0, latest - 5));
this.stopCursors = await subscribe(this.path("cursors"), {
limit: 200,
onData: (rows: CursorRow[]) => { this.cursorRows = rows || []; this.showCursors(); },
onError: () => null,
});
this.idleTimer = window.setInterval(() => {
if (document.visibilityState === "visible" && Date.now() - this.mineSentAt * 1000 > POINTER_REFRESH_MS) this.sendPointer();
this.showCursors();
}, 5_000);
void this.loadFiles(api.getSceneElementsIncludingDeleted());
this.events.onSave(this.canEdit ? "saved" : "readonly");
}
async stop() {
this.closed = true;
window.clearTimeout(this.flushTimer);
window.clearTimeout(this.cursorTimer);
window.clearInterval(this.idleTimer);
await Promise.all([this.follow.stop?.(), this.stopCursors?.()].map((p) => p?.catch(() => null)));
if (this.pending.size) await this.flush().catch(() => null);
await set(this.path(`cursors/${this.uid}`), null).catch(() => null);
}
/** Takes this person's pointer off the board when they leave the page. */
leave() {
void set(this.path(`cursors/${this.uid}`), null).catch(() => null);
}
/** The elements a board starts with: drawn locally, and saved when this device can edit. */
addSeed(elements: readonly ExcalidrawElement[]) {
if (!this.canEdit) return;
for (const el of elements) this.pending.set(el.id, el);
this.schedule(0);
}
// ---- Elements -------------------------------------------------------------------------------
/** Called on every editor change: queues the elements whose version went past what the server has. */
onLocalChange(elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) {
if (this.closed) return;
const drawing = appState.newElement?.type === "freedraw" ? appState.newElement.id : null;
if (this.canEdit) {
let queued = false;
for (const el of elements) {
if (el.id === drawing || invisible(el)) continue;
// An image waits for its file: Excalidraw removes the placeholder outright if the file is refused.
if (el.type === "image" && !el.isDeleted && (!el.fileId || !files[el.fileId])) continue;
if (el.version > (this.known.get(el.id) ?? -1)) {
this.pending.set(el.id, el);
queued = true;
}
}
if (queued) this.schedule(FLUSH_MS);
const fileKey = Object.keys(files).join(",");
if (fileKey !== this.lastFiles) {
this.lastFiles = fileKey;
for (const file of Object.values(files)) void this.upload(file, elements);
}
}
// Others see what this person has selected, so send the pointer again when the selection changes.
if (this.cursorNext && selection(appState) !== this.cursorSel) this.sendPointer();
}
private schedule(ms: number) {
if (this.flushTimer) return;
this.flushTimer = window.setTimeout(() => { this.flushTimer = 0; void this.flush(); }, ms);
}
async flush(): Promise<void> {
if (this.flushing || !this.pending.size || !this.canEdit) return;
this.flushing = true;
this.events.onSave("saving");
const batch: { el: ExcalidrawElement; json: string; version: number }[] = [];
let chars = 0;
for (const [id, el] of this.pending) {
const json = JSON.stringify(el);
if (byteLength(json) > LIMITS.element) {
this.pending.delete(id);
if (!this.tooBig.has(id)) {
this.tooBig.add(id);
this.events.onNotice("One drawing is too big to save. Draw it as a few shorter strokes.");
}
continue;
}
if (batch.length >= BATCH_ITEMS || chars + json.length > BATCH_CHARS) break;
batch.push({ el, json, version: el.version });
chars += json.length;
}
try {
if (batch.length) {
await setMany(batch.map(({ el, json }) => ({ path: this.path(`els/${el.id}`), document: { json, at: serverTimestamp() } })));
for (const { el, version } of batch) {
this.known.set(el.id, Math.max(this.known.get(el.id) ?? -1, version));
if ((this.pending.get(el.id)?.version ?? 0) <= version) this.pending.delete(el.id);
}
}
this.failures = 0;
} catch (err) {
if (isDenied(err)) {
this.canEdit = false;
this.pending.clear();
this.events.onDenied();
this.events.onSave("readonly");
this.flushing = false;
return;
}
this.failures += 1;
}
this.flushing = false;
if (!this.pending.size) return this.events.onSave("saved");
if (!this.failures) return this.schedule(FLUSH_MS);
// Offline: try again after 1s, 2s, 4s... up to 8 tries; after that, the next change or Retry tries again.
this.events.onSave("offline");
if (this.failures < MAX_FAILURES) this.schedule(Math.min(30_000, 500 * 2 ** this.failures));
}
/** Tries unsaved changes again now. */
retry() {
this.failures = 0;
this.schedule(0);
}
private async followFrom(from: number) {
const previous = this.follow.stop;
this.follow.from = from;
let first = true;
const stop = await subscribe(this.path("els"), {
filter: { at: { $gte: from } },
limit: 1000,
onData: (rows: Row[]) => {
if (first) {
first = false;
void previous?.().catch(() => null);
}
this.applyRemote(rows || []);
},
onError: () => null,
});
this.follow.stop = stop;
}
private applyRemote(rows: Row[]) {
const api = this.api;
if (!api || this.closed) return;
const remote: ExcalidrawElement[] = [];
let latest = 0;
for (const r of rows) {
latest = Math.max(latest, r.at || 0);
const el = parse(r.json);
if (!el || el.version <= (this.known.get(el.id) ?? -1)) continue;
remote.push(el);
}
if (rows.length > FOLLOW_MAX && !this.follow.moving && latest - 5 > this.follow.from) {
this.follow.moving = true;
void this.followFrom(latest - 5).finally(() => { this.follow.moving = false; });
}
if (!remote.length) return;
const restored = restoreElements(remote, null) as unknown as RemoteExcalidrawElement[];
for (const el of restored) this.known.set(el.id, Math.max(this.known.get(el.id) ?? -1, el.version));
const merged = reconcileElements(api.getSceneElementsIncludingDeleted(), restored, api.getAppState());
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER });
void this.loadFiles(merged);
}
// ---- Pointers -------------------------------------------------------------------------------
/** Where this person's pointer is, in board coordinates. Sent a few times a second at most. */
pointer(x: number, y: number, tool: "pointer" | "laser", down: boolean) {
this.cursorNext = { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10, tool, down };
this.sendPointer();
}
private sendPointer() {
if (this.cursorTimer || this.closed) return;
this.cursorTimer = window.setTimeout(() => {
this.cursorTimer = 0;
const p = this.cursorNext;
if (!p || !this.api) return;
this.cursorSel = selection(this.api.getAppState());
const sentAt = now();
set(this.path(`cursors/${this.uid}`), { ...p, sel: this.cursorSel, name: this.name().slice(0, 60), at: serverTimestamp() })
.then(() => { this.mineSentAt = sentAt; })
.catch(() => null);
}, CURSOR_MS);
}
private showCursors() {
const api = this.api;
if (!api || this.closed) return;
const mine = this.cursorRows.find((r) => r.id === this.uid);
if (mine && this.mineSentAt) this.skew = mine.at - this.mineSentAt;
const serverNow = now() + this.skew;
const collaborators = new Map<SocketId, Collaborator>();
for (const r of this.cursorRows) {
if (r.id === this.uid || serverNow - r.at > IDLE_SECONDS) continue;
// Anyone on the board writes their own row, so its selection may be any JSON.
let sel: string[] = [];
try {
const parsed: unknown = JSON.parse(r.sel || "[]");
if (Array.isArray(parsed)) sel = parsed.filter((id): id is string => typeof id === "string");
} catch { /* ignore */ }
collaborators.set(r.id as SocketId, {
id: r.id,
socketId: r.id as SocketId,
username: r.name || "Guest",
pointer: { x: r.x, y: r.y, tool: r.tool === "laser" ? "laser" : "pointer" },
button: r.down ? "down" : "up",
selectedElementIds: Object.fromEntries(sel.map((id) => [id, true])),
});
}
api.updateScene({ collaborators });
}
// ---- Images ---------------------------------------------------------------------------------
private async upload(file: BinaryFileData, elements: readonly ExcalidrawElement[]) {
if (this.uploaded.has(file.id) || this.uploading.has(file.id) || !file.dataURL) return;
this.uploading.add(file.id);
try {
let blob = await (await fetch(file.dataURL)).blob();
if (!IMAGE_TYPES.includes(blob.type) || blob.size > LIMITS.imageBytes) blob = await shrink(blob);
if (blob.size > LIMITS.imageBytes) {
this.dropImage(file.id, elements);
this.events.onNotice("That image is too big. Images can be PNG, JPEG or WebP up to 2 MB.");
return;
}
await setFile(this.path(`files/${file.id}`), new File([blob], file.id, { type: blob.type }), { metadata: { by: this.uid } });
this.uploaded.add(file.id);
} catch (err) {
if (!isDenied(err)) this.events.onNotice("An image did not upload. Check your connection and add it again.");
} finally {
this.uploading.delete(file.id);
}
}
private dropImage(fileId: string, elements: readonly ExcalidrawElement[]) {
const api = this.api;
if (!api) return;
const ids = new Set(elements.filter((e) => e.type === "image" && e.fileId === fileId).map((e) => e.id));
api.updateScene({
elements: api.getSceneElementsIncludingDeleted().map((e) => (ids.has(e.id) ? { ...e, isDeleted: true, version: e.version + 1, versionNonce: Math.floor(Math.random() * 2 ** 31) } : e)),
captureUpdate: CaptureUpdateAction.NEVER,
});
}
/** Fetches images that elements show but this device does not have yet. A file still uploading is tried again a few times. */
private async loadFiles(elements: readonly ExcalidrawElement[]) {
const api = this.api;
if (!api) return;
const have = api.getFiles();
const missing = [...new Set(elements.filter((e) => e.type === "image" && !e.isDeleted && e.fileId && !have[e.fileId]).map((e) => (e as { fileId: FileId }).fileId))]
.filter((id) => !this.fetching.has(id));
if (!missing.length) return;
missing.forEach((id) => this.fetching.add(id));
try {
const res = (await getFiles(this.path("files"))) as { data?: { path: string; url: string; metadata?: { contentType?: string } }[] };
const byId = new Map((res?.data || []).map((f) => [f.path.split("/").pop()!, f]));
const loaded: BinaryFileData[] = [];
for (const id of missing) {
const f = byId.get(id);
if (!f?.url) continue;
const blob = await (await fetch(f.url)).blob();
loaded.push({ id, dataURL: (await dataUrl(blob)) as BinaryFileData["dataURL"], mimeType: (f.metadata?.contentType || blob.type) as BinaryFileData["mimeType"], created: Date.now() });
this.uploaded.add(id);
}
if (loaded.length) api.addFiles(loaded);
} catch { /* tried again below */ }
missing.forEach((id) => this.fetching.delete(id));
const still = missing.filter((id) => !api.getFiles()[id]);
for (const id of still) {
const tries = (this.fetchTries.get(id) ?? 0) + 1;
this.fetchTries.set(id, tries);
if (tries <= 6) window.setTimeout(() => void this.loadFiles(api.getSceneElementsIncludingDeleted()), 1000 * 2 ** tries);
}
}
}
const selection = (appState: AppState) => JSON.stringify(Object.keys(appState.selectedElementIds || {}).slice(0, 60));
function dataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(blob);
});
}
/** Re-encodes an image as WebP, scaling it down until it fits the size limit (or giving up). */
async function shrink(blob: Blob): Promise<Blob> {
const bitmap = await createImageBitmap(blob);
let scale = Math.min(1, 2400 / Math.max(bitmap.width, bitmap.height));
let out = blob;
for (let i = 0; i < 4; i++) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
out = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b || blob), "image/webp", 0.85));
if (out.size <= LIMITS.imageBytes && IMAGE_TYPES.includes(out.type)) break;
scale *= 0.7;
}
bitmap.close();
return out;
}
Change history
Point OpenApps links to openapps.free
Bind AGENT_ADDRESS in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync