Steve Ruiz

  1. Home
  2. About
  3. Archive

Copy to Multiplayer Project

Copy to Multiplayer Project

I recently implemented a feature in tldraw that lets a user copy the contents of their current page into a new multiplayer project. There are still some issues around images and videos (as these need to be converted and uploaded separately) but it should be a pretty handy feature if you want to share a project or collaborate on something. Just send the link!

In this post, I'll walk through how it works.

Liveblocks Storage

tldraw's multiplayer implementation is powered by Liveblocks, a product that offers real time collaboration as a service. We use their presence API for cursor locations and selection, as well as their storage API for synchronizing the document. While there's plenty of client-side conflict resolution to make it all work, it's been a great "drop-in" solution for avoiding conflicts in a project's source of truth.

A few weeks ago, Liveblocks updated their REST API to include endpoints for reading and writing directly to a room's storage, which is what let me write the new feature.

Our own API Endpoint

To make this feature work, I first needed to create an API endpoint on tldraw.com that could act as an intermediary between the tldraw client application and the Liveblocks API. The tldraw app would send data to the tldraw.com API endpoint, where the site's server would authenticate with Liveblocks and then send the data to Liveblocks. If all went well, my endpoint would then send back the new multiplayer project's URL and the app would navigate to that URL.

A diagram showing which requests will be made between the app, the endpoint, and the Liveblocks API.

We'll need to communicate between the tldraw client application, an API endpoint in tldraw.com, and the Liveblocks API endpoints.

🚀 Remember, tldraw is open source! You can find the finished endpoint here.

Being a Next.js project, creating the endpoint was no problem. I made a new file at /pages/api/create.ts that expected to receive data from the application in the request body. In my case, the body is going to contain the user's tldraw document and their current pageId.

// pages/api/create.ts

import { NextApiRequest, NextApiResponse } from "next"

type storageJson = {
  pageId: string
  document: TDDocument
}

export default async function CreateMultiplayerRoom(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    // 1. Get an authentication token from Liveblocks
    // 2. Create the Liveblocks storage JSON
    // 3. Post the JSON and token to Liveblocks
  } catch (e) {
    res.send({ status: "error", message: e.message })
  }
}

The endpoint would need to do three things:

  1. Get an authentication token from Liveblocks
  2. Create the Liveblocks storage JSON
  3. Post the JSON and token to Liveblocks

Getting the Authentication Token

Authenticating with Liveblocks requires a private key. Because I have been using Liveblocks for the site's multiplayer service, I already had my keys in an .env file. And, because tldraw is deployed on Vercel, I had these in my project's environment variables as well.

Here's what they look like in my .env file:

// .env

NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY = pk_live_whatever
LIVEBLOCKS_SECRET_KEY = sk_live_whatever

Ok, back to my pages/api/creates.ts endpoint. With my keys in the environment, I next needed to get my authentication token from Liveblocks via their authorize endpoint.

const { token } = await fetch("https://liveblocks.io/api/authorize", {
  headers: {
    Authorization: `Bearer ${process.env.LIVEBLOCKS_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
}).then((d) => d.json())

The authorize endpoint responds with the web token I'd need to communicate with Liveblocks' storage API.

Creating the Storage JSON

Before I could POST my data to the Liveblocks storage API endpoint, however, I first needed to turn my tldraw document object into the JSON format that Liveblocks expects to receive.

const { pageId, document } = JSON.parse(req.body) as RequestBody

const storageJson = {
  liveblocksType: "LiveObject",
  data: {
    version: 2.1,
    shapes: {
      liveblocksType: "LiveMap",
      data: {},
    },
    bindings: {
      liveblocksType: "LiveMap",
      data: {},
    },
    assets: {
      liveblocksType: "LiveMap",
      data: {},
    },
  },
}

const page = document.pages[pageId]

storageJson.data.shapes.data = page.shapes ?? {}
storageJson.data.bindings.data = page.bindings ?? {}
storageJson.data.assets.data = document.assets ?? {}

Luckily our models are fairly similar (tables of objects keyed under their id) so this wasn't much work.

Sending the Storage JSON to Liveblocks

Finally, with my authentication token and my storage JSON ready, I could create a unique ID for the roomId, stringify my storageJson, and post it to the Liveblocks API endpoint.

const roomId = Utils.uniqueId()

const result = await fetch(
  `https://liveblocks.net/api/v1/room/${roomId}/storage`,
  {
    method: "POST",
    body: JSON.stringify(storageJson),
    headers: {
      Authorization: `Bearer ${auth.token}`,
      "Content-Type": "application/json",
    },
  }
)

If all went well, my API endpoint would reply to the original tldraw client application's request with a response that contained the new project's URL, based on the roomId.

if (result.status === 200) {
  // If success, send back the url for the new multiplayer project
  res.send({
    status: "success",
    message: result.statusText,
    url: "/r/" + roomId,
  })
} else {
  throw Error(result.statusText)
}

The client would then navigate to the URL—and the user would see their same page content ready for them in the multiplayer editor.

🔗 You can see the application code for this feature here.

Other APIs

As a final note, there are a few other Liveblocks APIs related to storage that I may use in the future. In addition to creating storage for a room via a POST, I could also GET the storage for room or DELETE its data instead. Eventually, I may use the other two in order to avoid situations where a user tries to copy to a roomId that already exists.

For completeness sake, here's the code I'd use for GET and DELETE:

fetch(`https://liveblocks.net/api/v1/room/${roomId}/storage`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${auth.token}`,
    "Content-Type": "application/json",
  },
})
fetch(`https://liveblocks.net/api/v1/room/${roomId}/storage`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${auth.token}`,
    "Content-Type": "application/json",
  },
})

Thanks for reading!

Twitter
  • Reordering Part 2: Tables and Fractional Indexing

    Implementing reordering commands (Send to Back, Send Backward, Bring Forward, and Bring to Front) in using fractional indexing.

  • Increment a Name in TypeScript

    How to increment a name to avoid duplicates.

Steve Ruiz © 2022

hey click here