Quickstart

Two browsers on one document. Nothing of ours in the browser unless you want it.

1. Make a project

Create an account, which puts you in an organisation with a project already in it. The project's overview page gives you two things: its id, which goes in every token, and the collaboration endpoint. On the keys page, make a signing secret. It is shown once, so put it straight into your environment:

QUOIN_PROJECT_ID=...
QUOIN_SIGNING_SECRET=...

Keep the secret on your server. It signs tokens; anybody holding it can open any room in your project.

2. Mint a token where you already know who is asking

Your application decides who may read a document, because it is the only thing that knows. The token says which room, until when, which project, and an opaque id for the reader if you want people counted rather than connections. There is nothing to install for this: the format is an HMAC over base64url JSON.

// app/api/room-token/[room]/route.ts
import { createHmac } from 'node:crypto';

const sign = (claims: object, secret: string) => {
  const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
  return `${body}.${createHmac('sha256', secret).update(body).digest('base64url')}`;
};

export async function GET(request: Request, { params }: { params: Promise<{ room: string }> }) {
  const { room } = await params;

  // Your own rule about who may read this document. We never see it.
  const reader = await whoIsReading(request, room);
  if (reader === null) return new Response('Not your document.', { status: 403 });

  return new Response(
    sign(
      {
        room,
        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 8,
        project: process.env.QUOIN_PROJECT_ID,
        subject: reader.id,
      },
      process.env.QUOIN_SIGNING_SECRET!,
    ),
  );
}

About that expiry. A y-websocket provider holds the parameters it was built with, so it reconnects with the same token. Give it hours rather than minutes, or the first reconnect after it expires will be refused. If you would rather mint a fresh one per connection, that is what the purpose-built client does, and room tokens covers it.

3. Connect from the browser

Any y-websocket client works. The provider takes the endpoint, the room name, a Yjs document and the token as a query parameter.

'use client';

import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

const ENDPOINT = 'wss://quoincloud.com/api/collab';

export function useSharedDoc(room: string) {
  const [shared, setShared] = useState<{ doc: Y.Doc; provider: WebsocketProvider } | null>(null);

  useEffect(() => {
    let wanted = true;
    let provider: WebsocketProvider | null = null;
    const doc = new Y.Doc();

    void fetch(`/api/room-token/${encodeURIComponent(room)}`)
      .then((response) => response.text())
      .then((token) => {
        if (!wanted) return;
        provider = new WebsocketProvider(ENDPOINT, room, doc, { params: { token } });
        setShared({ doc, provider });
      });

    return () => {
      wanted = false;
      provider?.destroy();
      doc.destroy();
      setShared(null);
    };
  }, [room]);

  return shared;
}

4. Hand the document to the editor

From here it is the editor's business rather than ours. Pass the Yjs document and the provider's awareness to the collaboration and presence extensions, which is documented on the editor's own site. One thing to carry across from there: connect the canvas after the provider has synced, or two clients both seed an empty document.

5. Check it

Open the page in two browser windows and type in both. If nothing crosses, the server will have said why rather than simply dropping you: a refusal comes back as a denial frame, which y-websocket reports in the console and the purpose-built client raises as a denied event. The usual causes are a token that has expired, a project id that does not match the secret that signed it, a room name in the token that differs from the room being opened, and a project that has been archived, which refuses new connections until it is restored. The project's Rooms tab in the dashboard lists the document the moment it exists.