Skip to main content
Deploy a collaboration server to enable real-time document synchronization using our Yjs-based collaboration library.

Quick Start

See our complete production example here for a full working implementation.
1

Install the library

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

Create server

import { CollaborationBuilder, LoadFn, AutoSaveFn } from '@superdoc-dev/superdoc-yjs-collaboration';

const SuperDocCollaboration = new CollaborationBuilder()
  .withName('SuperDoc Collaboration service')
  .withDebounce(2000)
  .onLoad(async (params) => {
    // Load document state from storage
    return await loadDocument(params.documentId);
  })
  .onAutoSave(async (params) => {
    // Save document state to storage
    await saveDocument(params);
  })
  .build();
See CollaborationBuilder API for all available configuration methods.
3

Add WebSocket endpoint

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

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

CollaborationBuilder API

The CollaborationBuilder provides a fluent interface for configuring your collaboration service.

Configuration Methods

withName
function
required
Set the service identifier name
.withName('SuperDoc Collaboration service')
withDebounce
function
Set the debounce interval for auto-save in milliseconds (default: 5000ms)
.withDebounce(2000) // Save every 2 seconds
withDocumentExpiryMs
function
Set how long documents stay in memory after last user disconnects (default: 5000ms)
.withDocumentExpiryMs(300000) // 5 minutes
useExtensions
function
Add Yjs extensions for additional functionality
.useExtensions([customExtension])

Hook Methods

onConfigure
function
Configure the service after initialization
.onConfigure((config) => {
  // Modify service configuration
  console.log('Service configured:', config.name);
})
onAuthenticate
function
Authenticate users connecting to documents
.onAuthenticate(async ({ token, documentId, request }) => {
  // Validate token and return user context
  const user = validateToken(token);
  return { userId: user.id, name: user.name };
})
onLoad
function
required
Load document state from storage
.onLoad(async ({ documentId }) => {
  const state = await storage.get(documentId);
  return state; // Uint8Array or null
})
onAutoSave
function
required
Save document state to storage (called automatically based on debounce)
.onAutoSave(async ({ document, documentId }) => {
  const state = Y.encodeStateAsUpdate(document);
  await storage.save(documentId, state);
})
onChange
function
React to document changes (called on every edit - use sparingly!)
.onChange(({ documentId }) => {
  metrics.increment('document.edits');
})
onBeforeChange
function
Process changes before they’re applied to the document
.onBeforeChange((params) => {
  // Validate changes before they're applied
  console.log('Change about to be applied to document:', params.documentId);
  
  // You can access user context if authentication is set up
  if (params.userContext) {
    console.log('Change by user:', params.userContext);
  }
})
build
function
required
Build the collaboration service
.build() // Returns SuperDocCollaboration instance

Production Implementation Example

Here’s a complete server implementation based on the production example:
import 'dotenv/config';
import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import corsPlugin from '@fastify/cors';

import { CollaborationBuilder, LoadFn, AutoSaveFn } from '@superdoc-dev/superdoc-yjs-collaboration';
import { loadDocument, saveDocument } from './storage';

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

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

// Build collaboration service
const SuperDocCollaboration = new CollaborationBuilder()
  .withName('SuperDoc Collaboration service')
  .withDebounce(2000) // Save every 2 seconds
  .onLoad(async (params) => {
    try {
      const state = await loadDocument(params.documentId);
      
      // Fallback to default document if not found
      if (!state) {
        console.log("Loading default document");
        const defaultState = await loadDocument('default');
        return defaultState || null;
      }
      
      return state;
    } catch (error) {
      const err = new Error('Failed to load document: ' + error);
      err.name = 'LoadError';
      throw err;
    }
  } as LoadFn)
  .onAutoSave(async (params) => {
    try {
      await saveDocument(params);
    } catch (error) {
      const err = new Error('Failed to save document: ' + error);
      err.name = 'SaveError';
      throw err;
    }
  } as AutoSaveFn)
  .build();

// Health check endpoint
fastify.get('/health', async (request, reply) => {
  return { status: 'ok', timestamp: new Date().toISOString() };
});

// WebSocket collaboration endpoint
fastify.register(async function (fastify) {
  fastify.get('/doc/:documentId', { websocket: true }, async (socket, request) => {
    try {
      await SuperDocCollaboration.welcome(socket, request);
    } catch (error) {
      const err = error as Error;
      const errorHandler = errorHandlers[err.name] || errorHandlers.default;
      errorHandler(err, socket);
    }
  });
});

// Start server
const port = parseInt(process.env.PORT || '3050');
const host = '0.0.0.0';
fastify.listen({ port, host }, (err, address) => {
  if (err) {
    fastify.log.error(err);
    process.exit(1);
  }
  console.log(`Collaboration server running at ${address}`);
});

Hook Details

Load document state from your storage system.
documentId
string
required
Document identifier to load
params
CollaborationParams
required
Full parameters object containing documentId and other context
Returns: Uint8Array | null - Document state or null for new documents
.onLoad(async (params) => {
  try {
    const state = await loadDocument(params.documentId);
    
    // Handle fallback for missing documents (from production example)
    if (!state) {
      console.log("Document not found, loading default");
      const defaultState = await loadDocument('default');
      return defaultState || null;
    }
    
    return state; // Must be Uint8Array
  } catch (error) {
    // Throw named error for proper error handling
    const err = new Error('Failed to load document: ' + error);
    err.name = 'LoadError';
    throw err;
  }
})

// Example load function signature (matches production example)
const loadDocument = async (documentId: string): Promise<Uint8Array | null> => {
  // Your storage implementation
  const result = await db.query('SELECT state FROM documents WHERE id = $1', [documentId]);
  return result.rows[0]?.state || null;
};
Return null for new documents. The collaboration system will initialize an empty document.
Save document state to storage (called automatically based on debounce setting).
document
SharedSuperDoc
Yjs document instance containing the current state
documentId
string
required
Document identifier for storage
params
CollaborationParams
required
Full parameters object with additional context
import * as Y from 'yjs';

.onAutoSave(async (params) => {
  try {
    // Extract the current document state from the Yjs document
    const state = Y.encodeStateAsUpdate(params.document);
    
    // Save using your storage function
    await saveDocument(params.documentId, state);
    
    // Optional: Update metadata
    await updateDocumentMetadata(params.documentId, {
      lastModified: new Date(),
      size: state.byteLength
    });
  } catch (error) {
    // Throw named error for proper error handling
    const err = new Error('Failed to save document: ' + error);
    err.name = 'SaveError';
    throw err;
  }
})

// Example storage function signature
const saveDocument = async (documentId: string, state: Uint8Array): Promise<boolean> => {
  // Your storage implementation
  await db.upsert(documentId, state);
  return true;
};
Auto-save failures don’t disconnect users by default. Handle SaveError appropriately in your error handlers.
Authenticate users connecting to documents (optional but recommended).
token
string
Authentication token from client
documentId
string
required
Document being accessed
request
Request
required
Original HTTP request with headers and cookies
Returns: User context object or boolean, or throws error to deny access
.onAuthenticate(async (params) => {
  // JWT token validation
  try {
    const payload = jwt.verify(params.token, process.env.JWT_SECRET);
    
    // Check document permissions
    const hasAccess = await checkUserAccess(payload.userId, params.documentId);
    if (!hasAccess) {
      throw new Error('Access denied');
    }
    
    return {
      userId: payload.userId,
      name: payload.name,
      email: payload.email
    };
  } catch (error) {
    throw new Error('Authentication failed');
  }
})

// Available parameters in params:
// - documentId: string
// - token?: string  
// - headers?: IncomingHttpHeaders
// - cookies?: Record<string, string>
// - params?: Record<string, string>
If no onAuthenticate hook is provided, all connections are allowed.

Optional Hooks

Fires on every change - use sparingly!
document
Y.Doc
required
Yjs document
documentId
string
required
Document ID
.onChange(({ documentId }) => {
  // Light operations only
  metrics.increment('edits');
  updateLastActive(documentId);
})
Configure Yjs document on creation.
ydoc
Y.Doc
required
New Yjs document
.onConfigure(({ ydoc }) => {
  // Disable garbage collection
  ydoc.gc = false;
  
  // Add custom types
  ydoc.getMap('metadata');
})

Configuration

Builder Methods

withName
function
Set service identifier
.withName('collab-prod-1')
withDebounce
function
Autosave interval (milliseconds)
.withDebounce(2000) // Save every 2 seconds
withDocumentExpiryMs
function
Cache expiry when no users connected
.withDocumentExpiryMs(300000) // 5 minutes

Storage Implementations

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

const storage = {
  async get(documentId) {
    const { rows } = await pool.query(
      'SELECT data FROM documents WHERE id = $1',
      [documentId]
    );
    return rows[0]?.data || null;
  },
  
  async save(documentId, data) {
    await pool.query(
      `INSERT INTO documents (id, data, updated_at) 
       VALUES ($1, $2, NOW()) 
       ON CONFLICT (id) 
       DO UPDATE SET data = $2, updated_at = NOW()`,
      [documentId, Buffer.from(data)]
    );
  }
};

Framework Integration

  • Express
  • Fastify
import express from 'express';
import expressWs from 'express-ws';

const app = express();
expressWs(app);

const collab = new SuperDocCollaboration()
  // ... hooks
  .build();

app.ws('/collaboration/:documentId', (ws, req) => {
  collab.welcome(ws, req);
});

app.listen(3000);

Production Deployment

Environment Variables

# .env
NODE_ENV=production
WS_PORT=3000
JWT_SECRET=your-secret-key
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgres://user:pass@localhost/db

Docker Setup

FROM node:18-alpine

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

COPY . .

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

Scaling Considerations

For production scale:
  • Use Redis for document state (fast access)
  • Implement sticky sessions for WebSocket
  • Add health checks for load balancer
  • Monitor memory usage per document
  • Set connection limits per user

Security Checklist

1

Always use WSS in production

url: 'wss://collab.example.com' // Not ws://
2

Implement rate limiting

const rateLimit = new Map();

.onAuthenticate(async ({ token, request }) => {
  const ip = request.ip;
  if (rateLimit.get(ip) > 100) {
    throw new Error('Rate limit exceeded');
  }
  // ... rest of auth
})
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

Set connection limits

const connections = new Map();

.onAuthenticate(async ({ token }) => {
  const user = validateToken(token);
  const userConnections = connections.get(user.id) || 0;
  
  if (userConnections >= 5) {
    throw new Error('Connection limit reached');
  }
  
  connections.set(user.id, userConnections + 1);
  return user;
})

Monitoring

Health Check Endpoint

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    documents: collab.getActiveDocumentCount(),
    connections: collab.getConnectionCount(),
    uptime: process.uptime()
  });
});

Metrics

// Track key metrics
.onChange(() => {
  metrics.increment('document.edits');
})

.onAutoSave(() => {
  metrics.increment('document.saves');
})

.onAuthenticate(() => {
  metrics.increment('auth.attempts');
})

Examples

API Reference

Full builder API documentation:
CollaborationBuilder
class
Fluent builder for configuration