Skip to main content
Markdown-OS uses WebSockets to push real-time file change notifications from the server to connected clients. This enables live updates when markdown files are modified externally.

Connection

WebSocket Endpoint

ws://localhost:8000/ws
Protocol: WebSocket (RFC 6455) Authentication: None required Example Connection:
const ws = new WebSocket('ws://localhost:8000/ws');

ws.onopen = () => {
  console.log('WebSocket connected');
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = () => {
  console.log('WebSocket disconnected');
};
The server accepts the WebSocket connection immediately and adds the client to an internal broadcast hub.

Message Protocol

Client to Server

Clients can send any text message to keep the connection alive. The server does not process client messages but requires the connection to receive data to stay open. Keepalive Pattern:
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send('ping');
  }
}, 30000);  // every 30 seconds
The server ignores message content from clients. Sending messages is only necessary to prevent connection timeouts.

Server to Client

The server sends JSON messages when markdown files are modified externally by other processes or editors.

File Changed Event

Broadcast when a watched markdown file is modified, created, or moved. Message Type: file_changed File Mode Fields:
type
string
required
Always "file_changed"
content
string
required
Updated markdown content of the file
Folder Mode Fields:
type
string
required
Always "file_changed"
file
string
required
Relative path of the changed file
content
string
Updated markdown content (included if the file is readable)

Examples

File Mode

When running in single-file mode, the server watches one markdown file and broadcasts its content when changed. Server Message:
{
  "type": "file_changed",
  "content": "# Updated Heading\n\nSomeone edited this file externally!\n"
}
Client Handler:
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  
  if (message.type === 'file_changed') {
    // Update editor with new content
    editor.setValue(message.content);
    console.log('File updated externally');
  }
};

Folder Mode

When running in folder mode, the server watches all markdown files in the directory tree and broadcasts changes with the file path. Server Message (with content):
{
  "type": "file_changed",
  "file": "getting-started/installation.md",
  "content": "# Installation\n\nUpdated installation instructions...\n"
}
Server Message (without content): If the file cannot be read, content is omitted:
{
  "type": "file_changed",
  "file": "getting-started/installation.md"
}
Client Handler:
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  
  if (message.type === 'file_changed') {
    const changedFile = message.file;
    const currentFile = getCurrentFile();  // your app logic
    
    if (changedFile === currentFile) {
      // Update editor with new content
      if (message.content) {
        editor.setValue(message.content);
        console.log(`File ${changedFile} updated externally`);
      } else {
        // Content unavailable, refetch via GET /api/content
        fetchContent(changedFile);
      }
    } else {
      // Different file changed, update file tree UI
      refreshFileTree();
    }
  }
};

Event Triggers

The server uses Watchdog to monitor file system events. Monitored Events:
  • File modified (content changed)
  • File created (new markdown file)
  • File moved (renamed or relocated)
Filtering:
File Mode: Only changes to the target file trigger notifications.Folder Mode: Only markdown files (.md, .markdown) within the workspace trigger notifications.
Throttling: Events are throttled to 200ms intervals to prevent notification spam during rapid edits. Self-Write Suppression: The server ignores file system events for 500ms after processing a POST /api/save request to avoid echoing the client’s own saves.

Connection Lifecycle

Connection Established

  1. Client initiates WebSocket handshake to /ws
  2. Server accepts connection via websocket.accept()
  3. Server adds client to internal WebSocketHub
  4. Client is now subscribed to all file change broadcasts

Connection Maintenance

The server keeps the connection open by continuously awaiting messages:
while True:
    await websocket.receive_text()
Clients should send periodic keepalive messages or rely on browser/library defaults.

Connection Closed

Client-initiated close:
  1. Client closes WebSocket connection
  2. Server receives WebSocketDisconnect exception
  3. Server removes client from WebSocketHub
Server-initiated close:
  1. Server detects stale connection during broadcast
  2. Server catches RuntimeError when sending fails
  3. Server removes client from WebSocketHub
There is no explicit close handshake or goodbye message. Clients are silently removed when disconnected.

Broadcasting

The server uses a fan-out pattern to broadcast messages to all connected clients.

Broadcast Flow

  1. File system event detected by Watchdog observer
  2. Callback scheduled on event loop via loop.call_soon_threadsafe()
  3. Validation checks if event should be ignored (self-write, wrong file, throttled)
  4. Content read from disk (skipped if read fails)
  5. JSON payload constructed with type, file, and content
  6. Broadcast to all clients in WebSocketHub._clients set
  7. Stale clients removed if send fails with RuntimeError

Concurrency

The WebSocketHub uses an asyncio.Lock to protect the client set during:
  • Adding new connections
  • Removing disconnected clients
  • Iterating for broadcasts

Error Handling

Client Errors

Connection refused:
ws.onerror = (error) => {
  console.error('WebSocket connection failed:', error);
  // Retry with exponential backoff
  setTimeout(() => reconnect(), 1000);
};
Unexpected close:
ws.onclose = (event) => {
  if (!event.wasClean) {
    console.warn(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`);
    // Attempt reconnection
    reconnect();
  }
};

Server Errors

File read failure: If the server cannot read a changed file, it broadcasts the event without content:
{
  "type": "file_changed",
  "file": "docs/example.md"
}
Clients should handle missing content by refetching via GET /api/content. Broadcast failure: If sending to a client fails, the server silently removes that client and continues broadcasting to others.

Complete Example

React Hook

import { useEffect, useState } from 'react';

function useMarkdownSync(currentFile) {
  const [content, setContent] = useState('');
  
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:8000/ws');
    
    ws.onopen = () => {
      console.log('Connected to Markdown-OS');
    };
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      
      if (message.type === 'file_changed') {
        if (!currentFile || message.file === currentFile) {
          if (message.content) {
            setContent(message.content);
          } else {
            // Refetch content
            fetch(`/api/content?file=${message.file}`)
              .then(res => res.json())
              .then(data => setContent(data.content));
          }
        }
      }
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    ws.onclose = () => {
      console.log('Disconnected from Markdown-OS');
    };
    
    // Keepalive ping every 30 seconds
    const keepalive = setInterval(() => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send('ping');
      }
    }, 30000);
    
    return () => {
      clearInterval(keepalive);
      ws.close();
    };
  }, [currentFile]);
  
  return content;
}

export default useMarkdownSync;

Python CLI Monitor

import asyncio
import json
import websockets

async def monitor_changes():
    """Monitor markdown file changes via WebSocket."""
    uri = "ws://localhost:8000/ws"
    
    async with websockets.connect(uri) as websocket:
        print("Monitoring file changes...")
        
        try:
            async for message in websocket:
                data = json.loads(message)
                
                if data["type"] == "file_changed":
                    file_path = data.get("file", "current file")
                    has_content = "content" in data
                    
                    print(f"File changed: {file_path}")
                    if has_content:
                        lines = data["content"].count("\n") + 1
                        chars = len(data["content"])
                        print(f"  Lines: {lines}, Characters: {chars}")
        except websockets.exceptions.ConnectionClosed:
            print("Connection closed by server")
        except KeyboardInterrupt:
            print("\nStopping monitor...")

if __name__ == "__main__":
    asyncio.run(monitor_changes())

Technical Details

Implementation

  • Framework: FastAPI WebSocket support
  • File watcher: Watchdog library
  • Concurrency: asyncio with thread-safe event scheduling
  • Hub pattern: Single WebSocketHub manages all client connections

Performance

  • Broadcast latency: Typically < 50ms from file system event to client delivery
  • Scalability: Tested with 100+ concurrent WebSocket connections
  • Memory: ~1KB per connected client

Limitations

  • No message acknowledgment or delivery guarantees
  • No replay/history of missed events
  • No selective subscriptions (all clients receive all events)
  • File mode only watches a single file; folder mode watches all .md files

See Also

Build docs developers (and LLMs) love