Getting Started with Node.js Collaboration Server

9 Sep 202624 minutes to read

This walk-through creates a collaborative PDF Viewer backed by the Node.js Collaboration Server. It uses the common collaborator services on the server and the shared @syncfusion/ej2-collaborator client on the browser.

The walk-through uses the PDF Viewer as the reference editor component. The DOCX Editor and Spreadsheet do not currently have a Node.js Server implementation, so this Node.js applies only to the PDF Viewer.

Client Side

Step 1 — Install the client packages

In your front-end project:

npm install @syncfusion/ej2-collaborator

Step 2 — Reference Adapter (PdfViewerAdapter.ts)

The control-specific translator on the client side. It implements ICollaborationProvider for the EJ2 PDF Viewer

import { PdfViewer } from '@syncfusion/ej2-pdfviewer';
import { CollaborativeEditingHandler } from '../collaboration/collaborative-editing-handler';
import { ICollaborationProvider, ICollaborationActionData } from '@syncfusion/ej2-collaborator';

export class PdfViewerAdapter implements ICollaborationProvider {
    private collaborativeEditingHandler: CollaborativeEditingHandler;
    private fileName: string = '';
    public currentRoomName: string = '';
    private isDocumentLoaded: boolean = false;
    private currentUser: string = '';
    private connectionId: string = '';

    constructor(
        private viewer: PdfViewer,
        private serviceUrl: string,
        currentUser: string
    ) {
        this.currentUser = currentUser;
        this.connectionId = this.generateConnectionId();        
        // Initialize the source-level collaboration handler
        this.collaborativeEditingHandler = new CollaborativeEditingHandler(
            viewer,
            currentUser
        );       
        
    }
 
    private generateConnectionId(): string {
        return `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    }
    
    public async loadFromServer(fileName?: string): Promise<string> {
        this.isDocumentLoaded = false;
        this.fileName = fileName || 'document.pdf';

        const roomName: string = this.getRoomName();
        this.currentRoomName = roomName;

        try {         

            const response = await fetch(
                `${this.serviceUrl}api/collaboration/ImportFile`,
                {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        roomName: roomName,
                        fileName: this.fileName,
                        currentUser: this.currentUser,
                        connectionId: this.connectionId
                    })
                }
            );

            if (!response.ok) {
                throw new Error(
                    `Failed to join collaboration room: ${response.statusText}`
                );
            }

            const responseText: string = await response.text();
            await this.open(responseText, roomName);           
            return roomName;
        } catch (error) {           
            throw error;
        }
    }

    public async open(responseText: string, roomName: string): Promise<void> {
        try {
            const data: any = JSON.parse(responseText);

            // Extract version for document state
            const version = data.version || data.currentVersion || 0;
            
            // Update collaboration handler with room info and version tracking
            this.collaborativeEditingHandler.updateRoomInfo(
                roomName,
                version,
                `${this.serviceUrl}api/collaboration/`
            );
            // Apply initial state: annotations, form fields, page organizer snapshots
            // These are stored as snapshots, not incremental operations
            if (data.operations && data.operations.length > 0) {
               ( const op of data.operations) {
                    this.applyRemoteAction(op.type, op);
                }
            }
            this.isDocumentLoaded = true;            
        } catch (error) {            
            throw error;
        }
    }

    
    public async sendActionToServer(operations: any[]): Promise<void> {
        try {
            if (!operations || operations.length === 0) {
                console.warn('[PdfViewerAdapter] No operations to send');
                return;
            }
            // Delegate to handler which manages routing and UpdateAction API calls
            await this.collaborativeEditingHandler.sendActionToServer(operations);           
        } catch (error) {
            console.error('[PdfViewerAdapter] Error sending operations:', error);
            throw error;
        }
    }

    
    public applyRemoteAction(action: string, data: ICollaborationActionData | any): void {
        try {
            if (!data) {              
                return;
            }

            // Extract payload if wrapped in ICollaborationActionData
            const payload = (data as ICollaborationActionData).payload || data;
            const actionType = action || payload.type || 'unknown';            
            // Echo detection: filter out sender's own operations
            if (payload.connectionId === this.connectionId) {
               
                return;
            }
            // Route based on action type
            if (actionType === 'addUser') {
                this.handleUserPresence('addUser', payload);
            } else if (actionType === 'removeUser') {
                this.handleUserPresence('removeUser', payload);
            } else if (actionType === 'connectionId') {
                this.handleConnectionId(payload);
            } else if (actionType === 'Save' || payload.action === 'Save') {
                // Save actions bypass echo filtering and standard routing                
                this.handleRemoteSaveRequest(payload);
            } else if (payload.type === 'annotation' || payload.type === 'formField' || 
                       payload.type === 'formFieldAction' || payload.type === 'pageOrganizer') {
                // Standard collaboration operations: delegate to handler
                this.collaborativeEditingHandler.applyRemoteAction(payload.type, payload);
            } else {                
            }
        } catch (error) {
            console.error('[PdfViewerAdapter] Error applying remote action:', error);
        }
    }   
    private getRoomName(fileName?: string): string {
        // Check for browser environment
        if (typeof window !== 'undefined') {
            const queryString: string = window.location.search;
            const urlParams: URLSearchParams = new URLSearchParams(queryString);
            let roomId: string | null = urlParams.get('id');

            if (!roomId) {
                roomId = Math.random().toString(32).slice(2);
                window.history.replaceState({}, '', `?id=${roomId}`);
            }

            return roomId;
        }

        // Server-side environment or fallback
        return Math.random().toString(32).slice(2);
    }   
   
}

Step 3- PDF Viewer initialization and collaborator client wiring

import {
    PdfViewer, Toolbar, Magnification, Navigation, LinkAnnotation, ThumbnailView, BookmarkView,
    TextSelection, TextSearch, Print, Annotation, FormFields, FormDesigner, PageOrganizer,
    AnnotationChangedEventArgs, FormFieldChangedEventArgs, FormFieldFocusOutEventArgs, PageOrganizerSavedEventArgs
} from "@syncfusion/ej2-pdfviewer";
import { PdfViewerAdapter } from "./collaboration/pdfViewerAdapter";
import { CollaborationClient } from '@syncfusion/ej2-collaborator';

// Initialize PDF Viewer with all necessary modules
PdfViewer.Inject(
    Toolbar, Magnification, Navigation, LinkAnnotation, ThumbnailView, BookmarkView,
    TextSelection, TextSearch, Print, Annotation, FormFields, FormDesigner, PageOrganizer
);
// Create PdfViewer instance
const viewer: PdfViewer = new PdfViewer();
// Enable collaborative editing on the viewer
viewer.enableCollaborativeEditing = true;
// Append viewer to DOM
viewer.appendTo("#pdfViewer");
let adapter: PdfViewerAdapter;
let client: CollaborationClient;

viewer.resourcesLoaded = async function () {
    

        try {
            // Get collaboration service URL from environment or use default
            const serviceUrl = 'http://localhost:3000/';
            adapterServiceUrl = serviceUrl;
            // Create adapter to bridge PdfViewer with collaboration service
            adapter = new PdfViewerAdapter(viewer, serviceUrl, currentUserName);

            // Create and configure collaboration client
            client = new CollaborationClient(adapter, {
                serviceUrl,
                connectionType: 'websocket',
                currentUser: currentUserName,
                onUserJoined: (user: any) => {
                    console.log('[Index] User joined collaboration:', user);
                },
                onUserLeft: (user: any) => {
                    console.log('[Index] User left collaboration:', user);
                }
            });           

            // Load document from server and join room asynchronously
            (async () => {
                try {
                    // Step 1: Load from server (gets room name and pending operations)
                    const roomName = await adapter.loadFromServer();
                    adapterCurrentRoomName = roomName;
                    // Step 2: Join the collaboration room with the client
                    await client.joinRoomAsync(roomName);                   
                    // Step 3: Fetch and load the current PDF document state
                    await fetchAndLoadPDFDocument();                    

                } catch (error) {
                    console.error('[Index] Error during collaboration initialization:', error);
                }
            })();

        } catch (error) {
            console.error('[Index] Error initializing collaboration:', error);            
        }
    
};
viewer.documentChanged = (args: AnnotationChangedEventArgs | FormFieldChangedEventArgs | FormFieldFocusOutEventArgs | PageOrganizerSavedEventArgs) => {
    try {
        // Handle annotation changes
        if ('annotationId' in args) {       

            let operations: any[];
            if ((args as any).action) {
                operations = [{
                    action: (args as any).action,
                    annotation: (args as any).annotationId,
                    type: 'annotation',
                    isRedacted: (args as any).isRedacted
                }];
            } else {
                // Handle annotation removal or cleanup
                operations = [{
                    type: 'removeUser',
                    client: client,
                    currentUser: currentUserName
                }];
            }

            adapter.sendActionToServer(operations);           
        }
        // Handle form field changes
        else if ('formField' in args) {        
            const operations = [{
                action: (args as any).action,
                formField: (args as any).formField,
                type: 'formField'
            }];

            adapter.sendActionToServer(operations);          
        }
        // Handle form field value updates (focus out)
        else if ('fieldName' in args) {            
            const field: any = args;
            const operations = [{
                action: 'formFieldUpdate',
                data: field,
                type: 'formField'
            }];

            adapter.sendActionToServer(operations);          
        }
        // Handle page organizer changes
        else if ('organizePageActions' in args) {           

            const eventData: any = args;
            const actionDetails: any = (args as any).organizePageActions && typeof ((args as any).organizePageActions) === 'string' 
                ? JSON.parse((args as any).organizePageActions) 
                : "";

            let operations: any[];

            if (eventData && eventData.savedDocument === null && actionDetails.action && actionDetails.action === 'applyCancelled') {
                // User cancelled the page organizer operation
                operations = [{
                    type: 'removeUser',
                    client: client,
                    currentUser: currentUserName
                }];
            }
            else if (eventData && eventData.savedDocument !== null && actionDetails.length > 0 && actionDetails[0].action !== 'applyCancelled') {
                // Page organizer operation applied successfully
                operations = [{
                    action: 'pageOrganizerUpdate',
                    data: (args as any).organizePageActions,
                    type: 'pageOrganizer'
                }];
            } else {
                // No valid operation to send
                return;
            }

            adapter.sendActionToServer(operations);        
        }

    } catch (error) {
        console.error('[Index] Error processing document change:', error);
    }
};

async function fetchAndLoadPDFDocument(): Promise<void> {
    try {       
        // Build query parameters for GetPDFDocument endpoint
        const queryParams = new URLSearchParams({
            roomName: adapterCurrentRoomName || 'default'
        });

        // Fetch PDF document from collaboration service
        const response = await fetch(
            `${adapterServiceUrl}api/collaboration/GetPDFDocument?${queryParams.toString()}`,
            {
                method: 'GET',
                headers: {
                    'Accept': 'application/json'
                }
            }
        );

        if (!response.ok) {
            throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
        }

        const result = await response.json();

        if (!result.success) {
            throw new Error(`Server error: ${result.error}`);
        }
      
        // Step 1: Decode Base64 content to binary string
        const binaryString = atob(result.content);

        // Step 2: Convert binary string to Uint8Array
        const bytes = new Uint8Array(binaryString.length);
        for (let i = 0; i < binaryString.length; i++) {
            bytes[i] = binaryString.charCodeAt(i);        }

        // Step 3: Create Blob from Uint8Array
        const pdfBlob = new Blob([bytes], { type: 'application/pdf' });     
        // Step 4: Load the PDF into the viewer
        await loadPDFBlobIntoViewer(pdfBlob);      
    } catch (error) {    
        throw error;
    }
}

/**
 * Loads a PDF blob into the viewer
 * 
 * Attempts to load using viewer.load() method with Uint8Array,
 * with fallback to data URL if needed.
 * 
 * @param pdfBlob - The PDF blob to load
 * @returns Promise that resolves when PDF is loaded
 */
async function loadPDFBlobIntoViewer(pdfBlob: Blob): Promise<void> {
    try {
        return new Promise<void>((resolve, reject) => {
            const reader = new FileReader();

            reader.onload = () => {
                try {
                    const arrayBuffer = reader.result as ArrayBuffer;
                    const uint8Array = new Uint8Array(arrayBuffer);

                    // Attempt to load using viewer.load() with Uint8Array
                    if (viewer.load && typeof viewer.load === 'function') {
                        viewer.load(uint8Array, '');
                        resolve();
                        return;
                    }

                    // Fallback: Try loading via data URL
                    const dataReader = new FileReader();
                    dataReader.onload = () => {
                        try {
                            const dataUrl = dataReader.result as string;

                            if (viewer.load && typeof viewer.load === 'function') {
                                viewer.load(dataUrl, '');                              
                                resolve();
                            } else {                              
                                reject(new Error('Viewer load method not available'));
                            }
                        } catch (error) {
                            reject(error);
                        }
                    };

                    dataReader.onerror = () => {
                        reject(new Error('Failed to read blob as data URL'));
                    };

                    dataReader.readAsDataURL(pdfBlob);

                } catch (error) {
                    reject(error);
                }
            };

            reader.onerror = () => {
                reject(new Error('Failed to read blob as array buffer'));
            };

            reader.readAsArrayBuffer(pdfBlob);
        });

    } catch (error) {
        console.error('[Index] Error loading PDF blob:', error);
        throw error;
    }
}

Step 4 — Serve the client

Build and serve the front-end application so the page is reachable at, for example, http://localhost:4000

Integrate Collaboration Server

Step 5 — Install the Node.js package

In your Node.js project, install the Node.js Collaboration Server:

npm install ej2-collaborator-server

Step 6 — Create the Node.js server

CollaborationServer is the entry point for the Node.js Collaboration Server. It hosts the HTTP + WebSocket server, mounts the REST routes under /api/collaboration/*, and runs the background save worker.

// server.js

const { CollaborationServer } \= require('ej2\-collaborator-server');

const PdfViewerAdapter = require('./adapters/PdfViewerAdapter');
const adapter 
const server = new CollaborationServer({
    port: 8080,
    redis: {
        host: '<redis-host>',
        port: 6379,
        username: 'default',
        password: '<redis-password>',
        tls: {}
    },
    adapter  
});
server.app.use(cors());
registerDocumentEditorRoutes(
    server.app,
    server.actionService,
    adapter,
    server
);
server.app.get('/api/test', (req, res) => {res.json({status: 'Node Server Running'});
});
server.start();

Step 7 — Add the PDF Viewer adapter

PdfViewerAdapter is the control-specific translator on the server side.

// adapters/ PdfViewerAdapter.js

const {
    ICollaborationAdapter,
    CollaborationServer
} = require('@syncfusion/ej2-collaborator-server');

    mapControlToGenericAction(controlAction) {

        return {

            roomName: controlAction.roomName,

            connectionId: controlAction.connectionId,

            currentUser: controlAction.currentUser,

            version: controlAction.version,

            clientVersion: controlAction.clientVersion,

            isTransformed: controlAction.isTransformed,

            data: JSON.stringify(controlAction.operations)

        };

    }
    mapGenericToControlAction(collaborationAction) {
        return {

            roomName: collaborationAction.roomName,
            connectionId: collaborationAction.connectionId,
            currentUser: collaborationAction.currentUser,
            version: collaborationAction.version,
            clientVersion: collaborationAction.clientVersion,
            isTransformed: collaborationAction.isTransformed,
            operations: collaborationAction.data

                ? JSON.parse(collaborationAction.data)

                : []

        };

    }
    async transformOperations(actions) {
        // The PDF Viewer runs its own OT via its collaborative editing handler.

        // Replace this with the control-specific transform routine.        
    }
    async processSaveRequestAsync(request) {
        // Replace this with the control-specific save routine.    
    }
}


module.exports = PdfViewerAdapter;

Step 8 — Add the Collaborative Editing Controller (Web Service Methods)

The collaboration controller provides the web service endpoints required by the PDF Viewer to participate in a collaborative session. These endpoints load documents, process collaboration actions, and retrieve updates from the server.

The following three web service methods are required:

Web Service Method Purpose
ImportFile Loads the source document, applies any pending collaboration actions, and returns the latest document state and server version to a newly connected client.
UpdateAction Receives editing actions from connected clients, performs operational transformation, persists the action, and broadcasts the updated action to other participants.
GetActionsFromServer Retrieves collaboration actions created after the client’s last synchronized version, allowing the client to catch up with the latest document state.
// controllers/collaborative\-editing\-controller.js

function registerCollaborativeEditingRoutes(app, actionService, adapter,server) {


   app.post('/api/CollaborativeEditing/ImportFile', async (req, res) => {
        try {
            const { roomName, fileName } = req.body;
 
            if (!roomName) {
                return res.status(400).json({ error: 'Room name is required' });
            }
 
            // Get all pending operations for this room
            const allActions = await actionService.getPendingOperations(roomName, 0, -1);
 
            // Return raw operations - client handles filtering/reconstruction
            const operations = (allActions || []).map((x) =>
                adapter.mapGenericToControlAction(x)
            );
 
            return res.json({ roomName, version: allActions.length, operations });
        } catch (e) {
            console.error('[ImportFile] Error:', e.message);
            return res.status(500).json({
                error: 'Failed to import file',
                details: e.message
            });
        }
    }); 
  
    app.post('/api/CollaborativeEditing/UpdateAction', async (req, res) => {
        try {
            const request = req.body;
 
            if (!request.roomName) {
                return res.status(400).json({ error: 'RoomName is required' });
            }
 
            if (!request.type) {
                return res.status(400).json({ error: 'Type is required' });
            }
 
           let data = null;
            let actionDescription = '';
 
            // Validate and extract type-specific data
            switch (request.type) {
                case 'annotation': {
                    const annotationData = request.data;
                    if (!annotationData || !annotationData.xfdfData) {
                        return res.status(400).json({
                            error: 'Data.xfdfData is required for annotation type'
                        });
                    }
                    data = annotationData;
                    actionDescription = 'Annotation updated';
                    break;
                }
 
                case 'formField': {
                    const formFieldData = request.data;
                    if (!formFieldData || !formFieldData.jsonData) {
                        return res.status(400).json({
                            error: 'Data.jsonData is required for formField type'
                        });
                    }
                    data = formFieldData;
                    actionDescription = 'Form field updated';
                    break;
                }
 
                case 'formFieldAction': {
                    const formFieldActionData = request.data;
                    if (!formFieldActionData || !formFieldActionData.changes) {
                        return res.status(400).json({
                            error: 'Data.changes is required for formFieldAction type'
                        });
                    }
 
                    // Validate changes JSON format
                    try {
                        const changesObj = JSON.parse(formFieldActionData.changes);
                        if (!changesObj) {
                            return res.status(400).json({
                                error: 'Invalid changes JSON format'
                            });
                        }
                    } catch (ex) {
                        return res.status(400).json({
                            error: 'Failed to parse changes JSON',
                            details: ex.message
                        });
                    }
 
                    data = formFieldActionData;
                    actionDescription = 'Form field action updated';
                    break;
                }
 
                case 'pageOrganizer': {
                    if (!request.data) {
                        return res.status(400).json({
                            error: 'Data is required for pageOrganizer type'
                        });
                    }
                    data = request.data;
                    actionDescription = 'Page organizer updated';
                    break;
                }
 
                default:
                    return res.status(400).json({
                        error: `Invalid action type: ${request.type}`
                    });
            }
 
            // Convert to CollaborationAction
            const collaborationAction = adapter.mapControlToGenericAction(request);
 
            // Store in Redis
            await actionService.addOperation(collaborationAction, adapter);
            // Get all pending operations for this room
            const allActions = await actionService.getPendingOperations(request.roomName, 0, -1);
 
            // Broadcast: Reconstruct a clean request with strongly-typed data
            // This ensures the transport layer receives properly formatted data that serializes correctly
            const broadcastRequest = {
                roomName: request.roomName,
                connectionId: request.connectionId,
                userName: request.userName,
                type: request.type,
                currentVersion: request.currentVersion,
                data: data
            };
 
            // Broadcast to other clients in room
            // Clients filter via connectionId to ignore their own updates
            if (transport && typeof transport.broadcastToRoom === 'function') {
                try {
                    await transport.broadcastToRoom(
                        request.roomName,
                        {
                            event: 'action',
                            data: broadcastRequest
                        }
                    );                   
                } catch (broadcastError) {
                    console.error('[UpdateAction] Broadcast error:', broadcastError.message);
                    // Continue even if broadcast fails - action is already stored
                }
            }
 
            return res.json({
                success: true,
                message: actionDescription,
                data: data
            });
        } catch (e) {
            console.error('[UpdateAction] Error:', e.message, e.stack);
            return res.status(500).json({
                error: 'Failed to update action',
                details: e.message
            });
        }
    });


    app.post('/api/CollaborativeEditing/GetActionsFromServer', async (req, res) => {

        try {

            const { roomName, version } = req.body;

            const actions = await actionService.getEffectivePendingVersion(

                roomName, version

            );

            res.json(

                actions.map(x => adapter.mapGenericToControlAction(x))

            );

        } catch (e) {

            console.error(e);

            res.status(500).json({ error: e.message });

        }

    });

}


module.exports = { registerCollaborativeEditingRoutes };

Step 9 — Run the application

After completing the client and server setup:

  1. Start the Redis server.

  2. Run the Node.js server:

  3. Run the client application:

  4. Open the application in multiple browser windows or tabs.

  5. Open the same document and make changes in one window.

Result

  • Changes are synchronized automatically across all connected users.

  • User join and leave events are reflected in real time.

  • Editing operations are stored in Redis and processed by the Node.js Collaboration Server.

  • Document changes are automatically saved when the configured saveThreshold is reached.