Fabric.js Document Engine

Search documentation

Find a page or section

中文

Custom backend

View as Markdown

A complete storage adapter for your own API, and the server side that goes with it.

Fabric.js backend storage is one object with two functions. Below is a full adapter for a REST API, and the server logic that keeps saving safe.

The rules

  1. Reject a save when the stored revision is not expectedRevision. Throw an error with code: "SAVE_CONFLICT", or use createConflictError.
  2. Skip that revision check when expectedRevision is null. The user chose to overwrite.
  3. Throw an error with retryable: false when trying again cannot help, such as for a permission error.
  4. Pass signal to fetch, so a save is cancelled when another document is opened.

The adapter

import type { DocumentStorage } from "fabricjs-document-engine";
 
export const restStorage: DocumentStorage = {
  async loadDocument(id) {
    const response = await fetch(`/api/documents/${encodeURIComponent(id)}`);
    if (response.status === 404) {
      throw Object.assign(new Error("Not found"), {
        code: "DOCUMENT_NOT_FOUND",
      });
    }
    if (!response.ok) throw new Error(`Loading failed with ${response.status}`);
    return response.json();
  },
 
  async saveDocument(document, { expectedRevision, signal }) {
    const response = await fetch(
      `/api/documents/${encodeURIComponent(document.id)}`,
      {
        method: "PUT",
        signal,
        headers: {
          "Content-Type": "application/json",
          "If-Match":
            expectedRevision === null ? "*" : String(expectedRevision),
        },
        body: JSON.stringify(document),
      }
    );
    if (response.status === 409 || response.status === 412) {
      throw Object.assign(new Error("Someone else saved this document"), {
        code: "SAVE_CONFLICT",
      });
    }
    if ([401, 403, 422].includes(response.status)) {
      throw Object.assign(new Error(`Save refused with ${response.status}`), {
        retryable: false,
      });
    }
    if (!response.ok) throw new Error(`Save failed with ${response.status}`);
    const { revision } = (await response.json()) as { revision: number };
    return { revision };
  },
};

document.revision is the next revision. If your backend assigns its own number, return it as { revision }.

The server

Run the revision check and the write as one step, so two saves can never both pass. In SQL:

UPDATE documents
SET body = $1, revision = revision + 1
WHERE id = $2 AND ($3::int IS NULL OR revision = $3)
RETURNING revision;

If no row comes back, answer 409. Checking first and writing second, in two queries, lets a second save slip in between.

Image upload while saving

Images that exist only in the user's tab are uploaded once, and the document stores the returned URL:

const engine = createDocumentEngine({
  canvas,
  storage: restStorage,
  assets: {
    upload: async ({ blob }) => {
      const body = new FormData();
      body.append("file", blob);
      const response = await fetch("/api/uploads", { method: "POST", body });
      const { url } = (await response.json()) as { url: string };
      return url;
    },
  },
});

Versions

To keep versions in your Fabric.js backend storage, add four methods to the adapter: saveVersion(version), listVersions(documentId), loadVersion(documentId, versionId) and deleteVersion(documentId, versionId). listVersions returns summaries without the document field, newest first.

When the adapter is done, run the adapter check.