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
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:
Updated markdown content of the file
Folder Mode Fields:
Relative path of the changed file
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
- Client initiates WebSocket handshake to
/ws
- Server accepts connection via
websocket.accept()
- Server adds client to internal
WebSocketHub
- 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:
- Client closes WebSocket connection
- Server receives
WebSocketDisconnect exception
- Server removes client from
WebSocketHub
Server-initiated close:
- Server detects stale connection during broadcast
- Server catches
RuntimeError when sending fails
- 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
- File system event detected by Watchdog observer
- Callback scheduled on event loop via
loop.call_soon_threadsafe()
- Validation checks if event should be ignored (self-write, wrong file, throttled)
- Content read from disk (skipped if read fails)
- JSON payload constructed with
type, file, and content
- Broadcast to all clients in
WebSocketHub._clients set
- 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
- 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