218 lines
7.4 KiB
Python
218 lines
7.4 KiB
Python
"""Backlot server — FastAPI app: board state API, SSE change feed, media.
|
|
|
|
The watcher observes ``projects/`` with watchfiles; on any change it bumps a
|
|
per-project version and wakes SSE subscribers, who tell the browser to
|
|
refetch state. The server never writes to project directories.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state
|
|
|
|
UI_DIR = Path(__file__).resolve().parent / "ui"
|
|
|
|
# Paths inside a project whose changes are pure noise for the board.
|
|
_IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"}
|
|
|
|
SSE_HEARTBEAT_SECONDS = 15
|
|
|
|
|
|
class ChangeHub:
|
|
"""Fan-out of project-change notifications to SSE subscribers."""
|
|
|
|
def __init__(self) -> None:
|
|
self._subscribers: set[asyncio.Queue] = set()
|
|
|
|
def subscribe(self) -> asyncio.Queue:
|
|
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
|
self._subscribers.add(q)
|
|
return q
|
|
|
|
def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
self._subscribers.discard(q)
|
|
|
|
def publish(self, project_id: str) -> None:
|
|
for q in list(self._subscribers):
|
|
try:
|
|
q.put_nowait(project_id)
|
|
except asyncio.QueueFull:
|
|
pass # subscriber is behind; it will refetch on next event
|
|
|
|
|
|
hub = ChangeHub()
|
|
|
|
|
|
def _project_of_change(path_str: str) -> Optional[str]:
|
|
"""Map a changed filesystem path to a project id (None = irrelevant)."""
|
|
try:
|
|
rel = Path(path_str).resolve().relative_to(PROJECTS_DIR.resolve())
|
|
except (ValueError, OSError):
|
|
return None
|
|
if not rel.parts:
|
|
return None
|
|
if _IGNORE_PARTS.intersection(rel.parts):
|
|
return None
|
|
return rel.parts[0]
|
|
|
|
|
|
async def _watch_projects() -> None:
|
|
"""Background task: watch projects/ and publish debounced changes."""
|
|
try:
|
|
from watchfiles import awatch
|
|
except ImportError:
|
|
return # watcher unavailable → board still works via manual refresh
|
|
if not PROJECTS_DIR.is_dir():
|
|
return
|
|
async for changes in awatch(PROJECTS_DIR, recursive=True, step=400):
|
|
touched: set[str] = set()
|
|
for _change, path_str in changes:
|
|
pid = _project_of_change(path_str)
|
|
if pid:
|
|
touched.add(pid)
|
|
for pid in touched:
|
|
hub.publish(pid)
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="Backlot", docs_url=None, redoc_url=None)
|
|
|
|
@app.on_event("startup")
|
|
async def _startup() -> None:
|
|
app.state.watch_task = asyncio.create_task(_watch_projects())
|
|
|
|
@app.on_event("shutdown")
|
|
async def _shutdown() -> None:
|
|
task = getattr(app.state, "watch_task", None)
|
|
if task:
|
|
task.cancel()
|
|
|
|
# ---- API ----------------------------------------------------------
|
|
|
|
@app.get("/api/health")
|
|
async def health() -> dict:
|
|
return {"ok": True, "app": "backlot"}
|
|
|
|
@app.get("/api/projects")
|
|
async def projects() -> list:
|
|
return await asyncio.to_thread(list_projects)
|
|
|
|
@app.get("/api/project/{project_id}/state")
|
|
async def project_state(project_id: str) -> dict:
|
|
project_dir = _safe_project_dir(project_id)
|
|
return await asyncio.to_thread(load_board_state, project_dir)
|
|
|
|
@app.get("/api/project/{project_id}/events")
|
|
async def project_events(project_id: str, request: Request) -> StreamingResponse:
|
|
_safe_project_dir(project_id) # 404 early for unknown projects
|
|
|
|
async def stream():
|
|
q = hub.subscribe()
|
|
try:
|
|
yield _sse({"type": "hello", "project_id": project_id})
|
|
while True:
|
|
if await request.is_disconnected():
|
|
return
|
|
try:
|
|
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
yield _sse({"type": "heartbeat", "ts": time.time()})
|
|
continue
|
|
if changed == project_id:
|
|
# Coalesce bursts: drain anything queued for this project.
|
|
while not q.empty():
|
|
try:
|
|
q.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
yield _sse({"type": "change", "project_id": project_id})
|
|
finally:
|
|
hub.unsubscribe(q)
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
})
|
|
|
|
@app.get("/api/library/events")
|
|
async def library_events(request: Request) -> StreamingResponse:
|
|
async def stream():
|
|
q = hub.subscribe()
|
|
try:
|
|
yield _sse({"type": "hello"})
|
|
while True:
|
|
if await request.is_disconnected():
|
|
return
|
|
try:
|
|
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
yield _sse({"type": "heartbeat", "ts": time.time()})
|
|
continue
|
|
while not q.empty():
|
|
try:
|
|
q.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
yield _sse({"type": "change", "project_id": changed})
|
|
finally:
|
|
hub.unsubscribe(q)
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
})
|
|
|
|
# ---- Media (range requests handled by FileResponse) ---------------
|
|
|
|
@app.get("/media/{project_id}/{file_path:path}")
|
|
async def media(project_id: str, file_path: str) -> FileResponse:
|
|
project_dir = _safe_project_dir(project_id)
|
|
target = (project_dir / file_path).resolve()
|
|
try:
|
|
target.relative_to(project_dir.resolve())
|
|
except ValueError:
|
|
raise HTTPException(status_code=403, detail="path escapes project")
|
|
if not target.is_file():
|
|
raise HTTPException(status_code=404, detail="media not found")
|
|
return FileResponse(target)
|
|
|
|
# ---- UI ------------------------------------------------------------
|
|
|
|
@app.get("/p/{project_id}")
|
|
async def board_page(project_id: str) -> FileResponse:
|
|
return FileResponse(UI_DIR / "board.html")
|
|
|
|
@app.get("/")
|
|
async def library_page() -> FileResponse:
|
|
return FileResponse(UI_DIR / "index.html")
|
|
|
|
if UI_DIR.is_dir():
|
|
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
|
|
|
|
return app
|
|
|
|
|
|
def _safe_project_dir(project_id: str) -> Path:
|
|
if "/" in project_id or "\\" in project_id or project_id in (".", ".."):
|
|
raise HTTPException(status_code=400, detail="invalid project id")
|
|
project_dir = PROJECTS_DIR / project_id
|
|
if not project_dir.is_dir():
|
|
raise HTTPException(status_code=404, detail=f"unknown project: {project_id}")
|
|
return project_dir
|
|
|
|
|
|
def _sse(payload: dict) -> str:
|
|
return f"data: {json.dumps(payload)}\n\n"
|
|
|
|
|
|
app = create_app()
|