Skip to main content
The server.py module provides the create_app() factory function that creates a FastAPI application with all routes, WebSocket support, and file system watching for the markdown editor.

create_app()

Create the FastAPI application for the markdown editor.
def create_app(
    handler: FileHandler | DirectoryHandler,
    mode: str = "file"
) -> FastAPI
handler
FileHandler | DirectoryHandler
required
File or folder access service. Use FileHandler for single-file mode or DirectoryHandler for folder mode.
mode
str
default:"file"
Editor mode, either "file" or "folder". Must match the handler type.
app
FastAPI
Configured FastAPI application with routes, static assets, WebSocket support, and file system watching.
The mode parameter must be either "file" or "folder". Any other value raises ValueError.
Raises:
  • ValueError - If mode is not "file" or "folder"

Example

from pathlib import Path
from markdown_os.file_handler import FileHandler
from markdown_os.directory_handler import DirectoryHandler
from markdown_os.server import create_app
import uvicorn

# Single-file mode
file_handler = FileHandler(Path("notes.md"))
app = create_app(file_handler, mode="file")

# Folder mode
dir_handler = DirectoryHandler(Path("./docs"))
app = create_app(dir_handler, mode="folder")

# Run the server
uvicorn.run(app, host="0.0.0.0", port=8000)

Application Lifecycle

The application uses FastAPI’s lifespan context manager to start and stop file system observers:
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: Start watchdog observer for file changes
    observer = Observer()
    observer.start()
    
    try:
        yield  # Application runs
    finally:
        # Shutdown: Stop observer and cleanup handlers
        observer.stop()
        observer.join(timeout=3)
        handler.cleanup()

HTTP Routes

GET /

Serve the editor web page.
@app.get("/")
async def read_root() -> FileResponse
response
FileResponse
The main editor application HTML page (index.html).

GET /favicon.ico

Redirect browser default favicon.ico requests to the SVG favicon.
@app.get("/favicon.ico")
async def favicon() -> RedirectResponse
redirect
RedirectResponse
302 redirect to /static/favicon.svg.

GET /api/mode

Return the current server mode.
@app.get("/api/mode")
async def get_mode() -> dict[str, str]
mode
dict[str, str]
Mode payload with value "file" or "folder".Example: {"mode": "folder"}

GET /api/file-tree

Return the markdown file tree for folder mode.
@app.get("/api/file-tree")
async def get_file_tree() -> dict[str, Any]
tree
dict[str, Any]
Nested folder/file structure (see DirectoryHandler.get_file_tree() for schema).
This endpoint is only available in folder mode. Returns 400 error in file mode.
Example Response:
{
  "type": "folder",
  "name": "docs",
  "path": "",
  "children": [
    {
      "type": "file",
      "name": "intro.md",
      "path": "intro.md"
    },
    {
      "type": "folder",
      "name": "guides",
      "path": "guides",
      "children": [...]
    }
  ]
}

GET /api/content

Return markdown content and metadata.
@app.get("/api/content")
async def get_content(file: str | None = None) -> dict[str, object]
str | None
Relative file path in folder mode (e.g., ?file=guides/intro.md). Not used in file mode.
response
dict[str, object]
Response containing:
  • content (str): Markdown content as UTF-8 string
  • metadata (dict): File metadata from FileHandler.get_metadata()
    • In folder mode, includes additional relative_path field
Raises:
  • HTTPException(400) - If file parameter missing in folder mode or path invalid
  • HTTPException(404) - If file does not exist
  • HTTPException(500) - If file read fails
Example Response:
{
  "content": "# Introduction\n\nWelcome to the docs...",
  "metadata": {
    "path": "/home/user/docs/intro.md",
    "size_bytes": 1234,
    "modified_at": 1709251234.567,
    "created_at": 1709240000.123,
    "relative_path": "intro.md"
  }
}

POST /api/save

Persist markdown content to disk with atomic file replacement.
@app.post("/api/save")
async def save_content(payload: SaveRequest) -> dict[str, object]
Request Body (SaveRequest):
content
str
required
Full markdown document content to save.
file
str | None
Relative file path in folder mode. Not used in file mode.
response
dict[str, object]
Response containing:
  • status (str): Always "saved" on success
  • metadata (dict): Updated file metadata after save
Raises:
  • HTTPException(400) - If file parameter missing in folder mode or path invalid
  • HTTPException(404) - If file does not exist
  • HTTPException(500) - If write fails
The server tracks internal writes with a timestamp to distinguish them from external file changes when file watching.
Example Request:
{
  "content": "# Updated Content\n\nNew paragraph...",
  "file": "guides/intro.md"
}
Example Response:
{
  "status": "saved",
  "metadata": {
    "path": "/home/user/docs/guides/intro.md",
    "size_bytes": 1456,
    "modified_at": 1709251456.789,
    "created_at": 1709240000.123,
    "relative_path": "guides/intro.md"
  }
}

POST /api/images

Save an uploaded image in the workspace images directory.
@app.post("/api/images")
async def upload_image(file: UploadFile) -> dict[str, str]
file
UploadFile
required
Uploaded image data from multipart form payload.
response
dict[str, str]
Response containing:
  • path (str): Relative image path for markdown (e.g., "images/photo-20240301-123456.png")
  • filename (str): Saved filename with timestamp
Constraints:
  • Allowed extensions: .png, .jpg, .jpeg, .gif, .webp, .svg, .bmp, .ico
  • Maximum size: 10 MB (10,485,760 bytes)
  • Filename sanitization: Non-alphanumeric characters replaced with hyphens, timestamp appended
Raises:
  • HTTPException(400) - If file format unsupported, empty, or too large
Example Response:
{
  "path": "images/screenshot-20240301-123456-789012.png",
  "filename": "screenshot-20240301-123456-789012.png"
}

GET /images/{filename:path}

Serve uploaded images from the workspace images directory.
@app.get("/images/{filename:path}")
async def serve_image(filename: str) -> FileResponse
filename
str
required
Relative filename under the images directory (e.g., "photo.png" or "subdir/photo.png").
response
FileResponse
Streamed image file response when present.
This endpoint validates that the path stays within the images directory. Directory traversal attempts (e.g., "../etc/passwd") return 400 error.
Raises:
  • HTTPException(400) - If path contains .. or escapes images directory
  • HTTPException(404) - If image not found

WebSocket Routes

WS /ws

Maintain WebSocket connections for external file-change notifications.
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None
websocket
WebSocket
required
Incoming WebSocket connection from the browser.
Message Format (Server → Client): File Mode:
{
  "type": "file_changed",
  "content": "# Updated markdown content..."
}
Folder Mode:
{
  "type": "file_changed",
  "file": "guides/intro.md",
  "content": "# Updated markdown content..."
}
The WebSocket connection stays open until the client disconnects. Clients should reconnect automatically if the connection drops.
Behavior:
  • Accepts connection and registers client in WebSocketHub
  • Keeps connection alive by receiving (and ignoring) client messages
  • Removes client on disconnect or error
  • Receives external file change notifications from watchdog observer

Helper Classes

WebSocketHub

Manage active WebSocket clients and fan-out messages.
class WebSocketHub:
    async def connect(self, websocket: WebSocket) -> None
    async def disconnect(self, websocket: WebSocket) -> None
    async def broadcast_json(self, payload: dict[str, str]) -> None
Methods:
connect
async method
Accept and register a new WebSocket client.
disconnect
async method
Remove a WebSocket client from the active set.
broadcast_json
async method
Send a JSON payload to all currently connected clients. Automatically removes stale clients that fail to receive.

MarkdownPathEventHandler

Watchdog handler for markdown file changes in file or folder mode.
class MarkdownPathEventHandler(FileSystemEventHandler):
    def __init__(
        self,
        notify_callback: Callable[[Path], None],
        should_ignore: Callable[[], bool],
        target_file: Path | None = None,
        root_directory: Path | None = None,
    ) -> None
Constructor Parameters:
notify_callback
Callable[[Path], None]
required
Callback invoked on external file changes with the changed file path.
should_ignore
Callable[[], bool]
required
Callback returning True to ignore events (used to skip internal writes).
target_file
Path | None
Single-file mode target markdown path. Mutually exclusive with root_directory.
root_directory
Path | None
Folder-mode workspace root path. Mutually exclusive with target_file.
Event Handling:
  • Listens for modified, moved, and created events
  • Filters out directory events (only files)
  • Validates events match target file or are markdown files in root directory
  • Throttles to max one notification per 0.2 seconds
  • Ignores events within 0.5 seconds of internal writes

App State

The FastAPI app stores state in app.state:
app.state.handler: FileHandler | DirectoryHandler  # File access handler
app.state.mode: str  # "file" or "folder"
app.state.current_file: str | None  # Current file in folder mode
app.state.websocket_hub: WebSocketHub  # WebSocket client manager
app.state.last_internal_write_at: float  # Timestamp of last save (monotonic)

Complete Server Example

from pathlib import Path
from markdown_os.directory_handler import DirectoryHandler
from markdown_os.server import create_app
import uvicorn
import asyncio

async def main():
    # Create directory handler
    docs_dir = Path("~/Documents/notes").expanduser()
    handler = DirectoryHandler(docs_dir)
    
    # Create FastAPI app
    app = create_app(handler, mode="folder")
    
    # Configure uvicorn
    config = uvicorn.Config(
        app,
        host="0.0.0.0",
        port=8000,
        log_level="info",
        access_log=True
    )
    
    server = uvicorn.Server(config)
    
    try:
        # Run server
        print(f"Starting server for {docs_dir}")
        print("Open http://localhost:8000 in your browser")
        await server.serve()
    except KeyboardInterrupt:
        print("\nShutting down...")
    finally:
        # Cleanup is handled automatically by app lifespan
        pass

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

File Watching Details

Watch Configuration

File Mode:
  • Watches: Parent directory of target file
  • Recursive: No
  • Events: Only for the specific target file
Folder Mode:
  • Watches: Entire workspace directory
  • Recursive: Yes
  • Events: All .md and .markdown files

Event Throttling

The file watcher implements two throttling mechanisms:
  1. Time-based throttling: Maximum one notification per 0.2 seconds
  2. Internal write filtering: Ignores events within 0.5 seconds of POST /api/save requests
def _should_ignore_watcher_event(app: FastAPI) -> bool:
    return (time.monotonic() - app.state.last_internal_write_at) < 0.5
This prevents the editor from receiving notifications for its own save operations, which would cause unnecessary UI updates and potential conflicts.

Static Files

Static assets are mounted from markdown_os/static/:
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
Available at:
  • /static/index.html - Main editor HTML
  • /static/js/*.js - JavaScript modules
  • /static/css/*.css - Stylesheets
  • /static/favicon.svg - Application icon

Error Handling

The server maps internal exceptions to HTTP status codes:
ExceptionStatus CodeScenario
FileReadError (“does not exist”)404File not found
FileReadError (other)500Read failure
FileWriteError500Write failure
FileNotFoundError404File not found
ValueError400Invalid path or parameter
def _status_for_read_error(error: FileReadError) -> int:
    if "does not exist" in str(error):
        return 404
    return 500

Source Reference

See the complete implementation in markdown_os/server.py.

Build docs developers (and LLMs) love