Skip to main content
Our official collaboration package for self-hosted deployments. Purpose-built for SuperDoc with a simple builder API.

Installation

npm install @superdoc-dev/superdoc-yjs-collaboration

Quick Start

Server

import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import { CollaborationBuilder } from '@superdoc-dev/superdoc-yjs-collaboration';

const fastify = Fastify({ logger: true });
fastify.register(websocketPlugin);

// Build collaboration service
const collaboration = new CollaborationBuilder()
  .withName('My Collaboration Server')
  .withDebounce(2000)  // Auto-save every 2 seconds
  .onLoad(async ({ documentId }) => {
    // Load document from your database
    return await db.getDocument(documentId);
  })
  .onAutoSave(async ({ documentId, document }) => {
    // Save document to your database
    await db.saveDocument(documentId, document);
  })
  .build();

// WebSocket endpoint
fastify.register(async (app) => {
  app.get('/doc/:documentId', { websocket: true }, (socket, request) => {
    collaboration.welcome(socket, request);
  });
});

fastify.listen({ port: 3050 });

Client

import { SuperDoc } from 'superdoc';

new SuperDoc({
  selector: '#editor',
  document: {
    id: 'document-123',
    type: 'docx'
  },
  user: {
    name: 'John Smith',
    email: '[email protected]'
  },
  modules: {
    collaboration: {
      url: 'ws://localhost:3050/doc',
      token: 'auth-token'
    }
  }
});

Builder API

The CollaborationBuilder provides a fluent interface:
const collaboration = new CollaborationBuilder()
  .withName('service-name')           // Service identifier
  .withDebounce(2000)                  // Auto-save interval (ms)
  .withDocumentExpiryMs(300000)        // Cache expiry after disconnect
  .onAuthenticate(authHandler)         // Validate users
  .onLoad(loadHandler)                 // Load documents
  .onAutoSave(saveHandler)             // Save documents
  .onChange(changeHandler)             // React to changes
  .onConfigure(configHandler)          // Configure Yjs doc
  .build();

Hooks

onLoad (Required)

Load document state from storage:
.onLoad(async ({ documentId }) => {
  const state = await db.query(
    'SELECT state FROM documents WHERE id = $1',
    [documentId]
  );

  if (!state) {
    // Return null for new documents
    return null;
  }

  // Return Uint8Array
  return state;
})

onAutoSave (Required)

Save document state to storage:
import * as Y from 'yjs';

.onAutoSave(async ({ documentId, document }) => {
  const state = Y.encodeStateAsUpdate(document);

  await db.query(
    `INSERT INTO documents (id, state, updated_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (id) DO UPDATE SET state = $2, updated_at = NOW()`,
    [documentId, Buffer.from(state)]
  );
})

onAuthenticate (Optional)

Validate users connecting to documents:
.onAuthenticate(async ({ token, documentId, request }) => {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);

    // Check document permissions
    const hasAccess = await checkAccess(payload.userId, documentId);
    if (!hasAccess) {
      throw new Error('Access denied');
    }

    return {
      userId: payload.userId,
      name: payload.name
    };
  } catch (error) {
    throw new Error('Authentication failed');
  }
})

onChange (Optional)

React to document changes (fires on every edit):
.onChange(({ documentId }) => {
  // Use sparingly - fires frequently
  metrics.increment('document.edits');
})

Storage Examples

  • PostgreSQL
  • S3
  • Redis
import { Pool } from 'pg';
const pool = new Pool();

// Load
.onLoad(async ({ documentId }) => {
  const { rows } = await pool.query(
    'SELECT state FROM documents WHERE id = $1',
    [documentId]
  );
  return rows[0]?.state || null;
})

// Save
.onAutoSave(async ({ documentId, document }) => {
  const state = Y.encodeStateAsUpdate(document);
  await pool.query(
    `INSERT INTO documents (id, state, updated_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (id) DO UPDATE SET state = $2, updated_at = NOW()`,
    [documentId, Buffer.from(state)]
  );
})

Error Handling

const errorHandlers = {
  LoadError: (error, socket) => {
    console.log('Document load failed:', error.message);
    socket.close(1011, 'Document unavailable');
  },
  SaveError: (error, socket) => {
    console.log('Save failed:', error.message);
    // Don't close connection for save errors
  },
  default: (error, socket) => {
    console.log('Unknown error:', error.message);
    socket.close(1011, 'Server error');
  }
};

app.get('/doc/:documentId', { websocket: true }, async (socket, request) => {
  try {
    await collaboration.welcome(socket, request);
  } catch (error) {
    const handler = errorHandlers[error.name] || errorHandlers.default;
    handler(error, socket);
  }
});

Production Deployment

Docker

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production

COPY . .

EXPOSE 3050
CMD ["node", "server.js"]

Environment Variables

NODE_ENV=production
PORT=3050
JWT_SECRET=your-secret-key
DATABASE_URL=postgres://user:pass@localhost/db

Health Check

fastify.get('/health', (request, reply) => {
  reply.send({
    status: 'healthy',
    documents: collaboration.getActiveDocumentCount(),
    connections: collaboration.getConnectionCount()
  });
});

Security

1

Use WSS in production

Always use encrypted WebSocket (wss://) in production
2

Implement authentication

Use the onAuthenticate hook to validate users
3

Validate document IDs

.onLoad(async ({ documentId }) => {
  // Prevent path traversal
  if (!/^[a-zA-Z0-9-]+$/.test(documentId)) {
    throw new Error('Invalid document ID');
  }
  // ... load document
})
4

Rate limit connections

Limit connections per user to prevent abuse

Resources

Next Steps