Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/lerichardv/patolab-platform/llms.txt

Use this file to discover all available pages before exploring further.

PatoLab’s report editor supports simultaneous multi-user editing of pathology report sections. This is powered by a standalone Node.js process that runs alongside the Laravel application and manages the real-time synchronization layer. The architecture combines TipTap (rich text editor) and Yjs (CRDT-based conflict-free document model) on the browser side with Hocuspocus v4 as the central WebSocket authority, which in turn communicates back to Laravel through a webhook API to persist document state.

Architecture Overview

Browser (TipTap + Yjs)
        │  WebSocket (port 1234)

editor-collaboration-server/ (Node.js + Hocuspocus v4)
        │  HTTP POST  /api/collaboration

Laravel Backend (ReportEditorController::handleWebhook)
        │  MySQL / SQLite

specimen_reports table (yjs_*_state + *_html columns)
Each pathology report field (macroscopy, microscopy, diagnosis, etc.) maps to a separate Hocuspocus room. When a user opens the editor, the browser connects via WebSocket to the collaboration server, which fetches the saved document state from Laravel, hydrates the Yjs document, and begins broadcasting incremental updates to all connected peers in that room. Changes are debounced for 1 second on the server before being persisted back to Laravel as both a raw binary Yjs state vector (base64) and a rendered HTML string. The collaboration server lives in the editor-collaboration-server/ directory at the root of the repository. It has its own package.json and must be started as a separate process from the Laravel application.

Setup

1

Navigate to the server directory

cd editor-collaboration-server
2

Install Node.js dependencies

npm install
Key dependencies installed (from package.json):
PackageVersionPurpose
@hocuspocus/server^4.1.0WebSocket collaboration engine
@hocuspocus/transformer^4.1.0Yjs ↔ TipTap JSON conversion
@tiptap/starter-kit^3.26.0Core TipTap extensions
yjs^13.6.31CRDT document model
express^5.2.1HTTP server for auxiliary endpoints
crossws^0.4.5WebSocket adapter bridging Express and Hocuspocus
dotenv^17.4.2Environment variable loading
3

Configure environment variables

Create a .env file inside editor-collaboration-server/ (or copy from .env.example if present). At minimum, set the webhook URL pointing to your Laravel app:
editor-collaboration-server/.env
WEBHOOK_URL=http://127.0.0.1:8000/api/collaboration
PORT=1234
Then, in the root Laravel .env, set the collaboration server URL for the frontend:
.env
VITE_COLLABORATION_SERVER_URL=http://127.0.0.1:1234
4

Start the development server

npm run dev
This uses nodemon for automatic restart on file changes. The server starts on http://127.0.0.1:1234 by default:
🚀 Express + Hocuspocus v4 backend listening on http://127.0.0.1:1234
The collaboration server must be running before any user opens the report editor. If the server is not reachable, the TipTap editor in the browser will fail to connect and document content will not load. Verify the server is up by hitting its health endpoint: GET http://127.0.0.1:1234/health — it should return {"status":"ok","service":"Hocuspocus Express Server"}.

Room Naming Convention

Hocuspocus rooms are identified by a document name string. PatoLab uses the following pattern:
report-{reportId}-{field}
Where {field} is one of the following report section identifiers:
Field keyDatabase column (HTML)Database column (Yjs state)
macroscopymacroscopy_htmlyjs_macroscopy_state
microscopymicroscopy_htmlyjs_microscopy_state
diagnosisdiagnosis_htmlyjs_diagnosis_state
clinical_detailsclinical_details_htmlyjs_clinical_details_state
comments_notescomments_notes_htmlyjs_comments_notes_state
protocolsprotocols_htmlyjs_protocols_state
legendlegend_htmlyjs_legend_state
report_datereport_dateyjs_report_date_state
sections_ordersections_orderyjs_sections_order_state
status(read-only, from specimen.status)
save-status(transient UI state)
For example, room report-42-macroscopy corresponds to the macroscopy section of specimen_reports row with id = 42.

Laravel Webhook: /api/collaboration

The collaboration server sends POST requests to /api/collaboration (handled by ReportEditorController::handleWebhook) for three lifecycle events. The document name is parsed using the regex report-(\d+)-(.+) to extract the reportId and field.

onConnect — Client connects to a room

Fired when a browser client establishes a WebSocket connection. Laravel responds with the saved binary Yjs state for that document field, base64-encoded. The server uses this to immediately restore the document to its last persisted state without a round-trip database query for each individual keystroke.
// Request body
{ "event": "onConnect", "payload": { "documentName": "report-42-macroscopy", "requestParameters": {} } }

// Laravel response
{ "document": "<base64-encoded Yjs binary state or null>" }

create — Room is first initialized

Called by the onLoadDocument hook when no binary state exists in the onConnect response (i.e., the document has never been edited collaboratively, or the Yjs state was cleared). Laravel returns the raw HTML content from the database, which the server then converts to a Yjs document via TiptapTransformer.toYdoc() to seed the initial collaborative state.
// Request body
{ "event": "create", "payload": { "documentName": "report-42-macroscopy", "requestParameters": {} } }

// Laravel response
{ "content": "<p>Gross examination reveals...</p>" }

onChange — Document content changes (debounced autosave)

Fired after a 1-second debounce following any edit. The server sends both the full binary Yjs state (base64-encoded) and the rendered HTML string. Laravel stores the binary state for future onConnect hydration and the HTML for display, PDF generation, and read-only views.
// Request body
{
  "event": "onChange",
  "payload": {
    "documentName": "report-42-macroscopy",
    "document": "<base64-encoded Yjs update>",
    "html": "<p>Updated gross examination content...</p>"
  }
}

// Laravel response
{ "status": "success" }

How the Server Works

The server is structured as a single index.js file (editor-collaboration-server/index.js) that wires together three layers: 1. Hocuspocus Engine A Hocuspocus instance is initialized with a custom webhook extension object (customWebhookExtension) that implements onConnect, onLoadDocument, and onChange hooks. These hooks call the Laravel /api/collaboration endpoint as described above. 2. Express HTTP Server An Express app handles auxiliary HTTP routes that the React frontend calls directly:
  • POST /api/dictate-chunk — receives audio chunks and transcribes them via the Grok STT API for voice dictation
  • POST /api/fix-grammar — sends report text to the Grok API for medical Spanish grammar correction and optionally injects the result back into the live Yjs document
  • POST /api/refresh-insumos — triggers a refresh of the supply/inventory document room
  • GET /health — returns server status
3. WebSocket Bridge (crossws) The HTTP server’s upgrade event is intercepted by crossws, which routes WebSocket handshakes to Hocuspocus. This allows Hocuspocus and Express to share the same TCP port (1234) — standard HTTP requests go to Express while WebSocket upgrade requests go to Hocuspocus.
editor-collaboration-server/index.js
const PORT = process.env.PORT || 1234;
server.listen(PORT, '0.0.0.0', () => {
    console.log(`🚀 Express + Hocuspocus v4 backend listening on http://127.0.0.1:${PORT}`);
});
TipTap Extensions registered on the server (must match the browser editor configuration exactly to ensure correct HTML serialization):
  • StarterKit (with undoRedo disabled — Yjs manages undo/redo history)
  • CustomImage (extended Image with alignment and resize attributes)
  • TableKit (resizable tables)
  • TextAlign (heading, paragraph, image)
  • Highlight (multicolor)
  • ImageGrid (custom node for multi-column image layouts)

Production Deployment

For production, the collaboration server must run continuously and restart automatically on failure. The project’s README.md documents a Supervisor configuration as the recommended approach. PM2 is an equally viable alternative if it is already part of your Node.js operations stack.

Option A: PM2

pm2 start editor-collaboration-server/index.js --name patolab-collab
pm2 save        # persist across reboots
pm2 startup     # generate systemd/init.d integration
Check status and logs:
pm2 status patolab-collab
pm2 logs patolab-collab
Create /etc/supervisor/conf.d/patolab-collaboration.conf:
[program:patolab-collaboration]
process_name=%(program_name)s_%(process_num)02d
directory=/var/www/patolab/editor-collaboration-server
command=/usr/bin/node index.js
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/patolab/editor-collaboration-server/supervisor.log
environment=PATH="/usr/bin:/usr/local/bin"
Then reload and start:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start patolab-collaboration:*
Update directory to match the absolute path of your editor-collaboration-server/ folder, and ensure command points to the correct node binary (which node). The user must have read/write access to that directory.

Nginx Proxy (WebSocket)

If Nginx sits in front of your application, you must configure it to forward WebSocket upgrade requests to the collaboration server port:
location /collab-ws {
    proxy_pass http://127.0.0.1:1234;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400;
}
Then update VITE_COLLABORATION_SERVER_URL to point to your proxied path instead of the direct port.

Build docs developers (and LLMs) love