canvas-free.openapps.xyz

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.
Owned app · rev 93fdaafeda16
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.
← All files
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
93fdaafeda · 18 hours ago
Bind AGENT_ADDRESS in policy constants
bf8f43b096 · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
56f1a381b2 · 20 hours ago
Bounded source sync
9ec707aeb8 · 1 day ago
Bounded source sync
5645481c23 · 1 day ago
Bounded source sync
c38fe71874 · 1 day ago
Bounded source sync
28c0350b40 · 1 day ago