2. Build a directory monitor with a live browser UI¶
Series overview · Previous: Product catalog · Next: LCL playground
The complete working code is available for reference. This guide starts from an empty directory and provides every file you need; create and extend them in the order shown.
Background and objective¶
Imagine a local folder where an export job, a developer and a browser all create files. A request can list that folder once, but it cannot tell an already-open browser about the next change. Scanning the whole directory inside every request also makes response time depend on disk speed and directory size.
The objective is to build a directory observatory with three cooperating parts: a managed background worker discovers changes, the API owns the latest inventory, and a static browser displays that inventory through server-sent events (SSE). The same API accepts new files and serves downloads, including nested paths. This is useful for inspecting a trusted local workspace without a database or a frontend build tool. Polling intentionally reports current state, not every edit.
What you will learn¶
| You will build | lcl-fastapi feature you will practice |
|---|---|
| An importable application with operator settings | LclFastAPI, app.target, .lclcfg, universal defaults and CLI overrides |
| One inventory shared by routes and the monitor | Business lifespan, get_config() and app.state resource ownership |
| A scanner that does not run on the API loop | background_workers, BackgroundWorkerContext, per-worker configuration and cooperative stop |
| Safe publication from a thread to SSE subscribers | submit_to_service() and the boundary between worker and API event loops |
| File routes and a browser served by the same service | Retained FastAPI request/response APIs, static mounting and framework route coexistence |
| An observable, stoppable service | Health/status, request IDs, separate worker logs, restart switches and the native CLI |
SSE framing, filesystem checks and the HTML/JavaScript are application code. lcl-fastapi supplies configuration, lifecycle and supervision around them; it does not supply a file-storage or SSE framework.
Before you start¶
Use CPython 3.14 on Windows or Linux and basic async Python. Stop any other lcl-fastapi service: run one service at a time on the machine. Installation needs package-index access, but the finished application needs no external account. Keep the listener on loopback and use trusted local input.
1. Create the application package¶
Create an empty project directory. No repository checkout is needed.
On Windows:
mkdir directory-observatory
cd directory-observatory
py -3.14 -m venv .venv
.venv\Scripts\python.exe -m pip install lcl-fastapi==0.5.0
mkdir directory_service
mkdir directory_service\static
On Linux:
mkdir directory-observatory
cd directory-observatory
python3.14 -m venv .venv
.venv/bin/python -m pip install lcl-fastapi==0.5.0
mkdir -p directory_service/static
Keep subsequent files and commands in this directory. Later commands use
lcl-fastapi and python as shorthand for .venv\Scripts\lcl-fastapi.exe and
.venv\Scripts\python.exe on Windows, or .venv/bin/lcl-fastapi and
.venv/bin/python on Linux. In PowerShell use curl.exe instead of curl.
Use a second terminal for HTTP and management commands while the server runs.
Create directory_service/__init__.py to make an importable package:
"""Recursive directory monitor tutorial package."""
Create pyproject.toml so the application and its static files can be installed together:
[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
[project]
name = "lcl-fastapi-directory-example"
version = "1.0.0"
requires-python = ">=3.14"
dependencies = ["lcl-fastapi==0.5.0"]
[tool.hatch.build.targets.wheel]
packages = ["directory_service"]
Install this local project with python -m pip install -e ..
The editable install lets later Python changes take effect after a service restart.
The dependency installs the framework; the new package contains your application.
Do not start the service until the step that creates its service object.
2. Define operator configuration¶
Create service.lclcfg. Comments and blank lines group settings by function;
the dotted keys are native LCL scopes, not JSON objects that you must assemble.
__LCL_VERSION__: 1
# Shared configuration
using f"{lcl_fastapi_defaults}"
# Application identity and import target
app.name: "directory-monitor"
app.version: "1.0.0"
app.target: "directory_service.app:service"
# HTTP serving and shutdown
server.host: "127.0.0.1"
server.port: 18085
server.workers: 1
# Logging and file destinations
logger.file.default.directory: "./logs"
# Managed background tasks
background_worker.monitor.directory: "./watched"
background_worker.monitor.interval_seconds: 0.5
background_worker.monitor.max_entries: 10000
# Business settings
business.max_upload_bytes: 10485760
using imports the framework defaults before your overrides. You keep the
default health/docs routes, request IDs and shutdown behavior while choosing a
local address. app.target means “import directory_service.app, then get its
service object”; you will create that object in step 5.
The monitor name will be the key in the Python worker registry, so its settings
live under background_worker.monitor. business.max_upload_bytes belongs to
the HTTP application. Both use ordinary LCL expressions, but your code must
validate application-specific ranges. Relative ./watched resolves from the
service working directory; keep it alongside this configuration for the guide.
Checkpoint: inspect your files: the package, metadata and configuration now exist. No folder scan, log file or thread should have started merely from creating or installing the package. Resource initialization belongs in lifespan.
3. Implement bounded filesystem operations¶
Create directory_service/files.py. First, make file paths relative to a trusted
root and reject traversal, links and junctions. Then scan nested entries into a
bounded snapshot, keeping errors visible. Finally, create new files with xb
so an upload cannot silently overwrite an existing file.
"""Filesystem operations kept outside the API event loop."""
import os
import stat
from pathlib import Path, PurePosixPath
from fastapi import HTTPException
def within(root: Path, name: str) -> Path:
"""Accept relative POSIX paths without links, junctions or traversal."""
parts = PurePosixPath(name).parts
if not parts or name.startswith("/") or "\\" in name or ":" in name:
raise HTTPException(400, "Use a relative path with forward slashes")
if any(part in {"..", "."} for part in name.split("/")):
raise HTTPException(400, "Path traversal is not allowed")
path = root
for part in parts:
path /= part
if path.is_symlink() or path.is_junction():
raise HTTPException(400, "Links and junctions are not served")
if not path.resolve().is_relative_to(root):
raise HTTPException(400, "Path is outside the configured directory")
return path
def scan(root: Path, limit: int) -> dict[str, object]:
"""Return a bounded recursive snapshot, retaining errors as visible state."""
entries: list[dict[str, object]] = []
errors: list[str] = []
if not root.is_dir():
return {
"entries": [],
"errors": ["Configured directory is unavailable"],
"truncated": False,
}
def failed(error: OSError) -> None:
errors.append(str(error))
for parent, directories, files in os.walk(root, followlinks=False, onerror=failed):
directories[:] = sorted(
name
for name in directories
if not (Path(parent) / name).is_symlink() and not (Path(parent) / name).is_junction()
)
for name in sorted([*directories, *files]):
path = Path(parent) / name
try:
info = path.lstat()
if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
continue
if path.is_junction():
continue
if len(entries) >= limit:
return {"entries": entries, "errors": errors, "truncated": True}
entries.append(
{
"path": path.relative_to(root).as_posix(),
"kind": "directory" if path.is_dir() else "file",
"bytes": info.st_size if path.is_file() else 0,
"modified_ns": info.st_mtime_ns,
}
)
except OSError as error:
failed(error)
return {"entries": entries, "errors": errors, "truncated": False}
def save(root: Path, name: str, content: bytes) -> None:
"""Create a new file, refusing to overwrite an existing entry."""
path = within(root, name)
path.parent.mkdir(parents=True, exist_ok=True)
try:
with path.open("xb") as output:
output.write(content)
except FileExistsError as error:
raise HTTPException(409, "File already exists") from error
These functions are synchronous because filesystem work is blocking. You will
call scan() in a managed background thread, and route-triggered save() through
asyncio.to_thread. Merely adding async def would not make filesystem calls
nonblocking. Entries carry relative paths, kind, bytes and modification time.
Checkpoint: save this small check as a separate scratch file and run it with
python. It verifies nested paths and cleanup without starting the service:
from pathlib import Path
from tempfile import TemporaryDirectory
from directory_service.files import save, scan
with TemporaryDirectory() as directory:
root = Path(directory).resolve()
save(root, "notes/hello.txt", b"hello")
entries = scan(root, 100)["entries"]
assert any(row["path"] == "notes/hello.txt" and row["bytes"] == 5 for row in entries)
4. Add a managed monitor and an API-owned inventory¶
Create directory_service/events.py. The Inventory stores the latest complete
snapshot and a queue for each browser. The synchronous monitor(context) entry
runs on its own lcl-fastapi background thread; it must not mutate asyncio queues
owned by the API loop.
Read the worker from bottom to top after entering it: context.run(...) resolves
its configuration on its own loop, scan() performs disk I/O in its thread, and
submit_to_service(publish).result() waits for publication on the API loop.
stop_event.wait(interval) makes the delay interruptible at shutdown.
"""Publish thread-produced snapshots using queues owned by the API loop."""
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from starlette.responses import StreamingResponse
from starlette.types import Receive, Scope, Send
from directory_service.files import scan
from lcl_fastapi import BackgroundWorkerContext
class EventStream(StreamingResponse):
"""Wait for real disconnects without cancelling receive between snapshots."""
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
version = tuple(map(int, scope.get("asgi", {}).get("spec_version", "2.0").split(".")))
if version < (2, 4):
# Starlette already owns a persistent disconnect listener here.
await super().__call__(scope, receive, send)
return
async with asyncio.TaskGroup() as tasks:
response = tasks.create_task(super().__call__(scope, receive, send))
disconnected = tasks.create_task(self.listen_for_disconnect(receive))
_, pending = await asyncio.wait(
(response, disconnected), return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
@dataclass
class Inventory:
root: Path
limit: int
snapshot: dict[str, object] = field(default_factory=lambda: {"entries": [], "errors": []})
revision: int = 0
subscribers: set[asyncio.Queue[dict[str, object]]] = field(default_factory=set)
async def publish(self, snapshot: dict[str, object]) -> None:
"""Coalesce slow consumers to the newest complete snapshot."""
if snapshot == self.snapshot:
return
self.snapshot = snapshot
self.revision += 1
for queue in self.subscribers:
if queue.full():
queue.get_nowait()
queue.put_nowait(self.current())
def current(self) -> dict[str, object]:
return {"revision": self.revision, **self.snapshot}
def monitor(context: BackgroundWorkerContext) -> None:
"""Scan recursively in the dedicated thread and bridge immutable snapshots."""
inventory: Inventory = context.app.state.inventory
interval = float(
str(context.run(context.get_config("background_worker.monitor.interval_seconds")))
)
if not 0.1 <= interval <= 60:
raise ValueError("monitor interval must be between 0.1 and 60 seconds")
while not context.stop_event.is_set():
snapshot = scan(inventory.root, inventory.limit)
async def publish(snapshot: dict[str, object] = snapshot) -> None:
await inventory.publish(snapshot)
context.submit_to_service(publish).result()
if context.stop_event.wait(interval):
break
publish() replaces a slow browser's pending item with the latest snapshot.
The queue cannot grow with missed revisions; this is current-state observation,
not an audit log. Polling can miss short-lived edits between scans.
EventStream will be used in step 7. It keeps one persistent disconnect listener.
Polling request.is_disconnected() cancels receive calls, which Gunicorn 26.2.0
can interpret as a closed connection. On ASGI before 2.4, Starlette already owns
that listener; on newer ASGI versions the small response class supplies it.
The API callback must remain nonblocking and must not wait synchronously for the monitor that submitted it. That would deadlock. Threads still share the GIL, locks and machine resources; see background workers.
5. Initialize the inventory and serve its first route¶
Create directory_service/app.py with the following code. It contains the imports
needed by later steps, the lifespan, worker registration and one inventory route.
Do not create the inventory or read get_config() at module import: the framework
configuration context is only active once a real API worker enters lifespan.
"""Static browser UI, file APIs and background-driven server-sent events."""
import asyncio
import json
import stat
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import FileResponse, Response, StreamingResponse
from starlette.staticfiles import StaticFiles
from directory_service.events import EventStream, Inventory, monitor
from directory_service.files import save, within
from lcl_fastapi import LclFastAPI, get_config
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configured = Path(str(await get_config("background_worker.monitor.directory")))
root = await asyncio.to_thread(configured.resolve)
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True)
limit = int(str(await get_config("background_worker.monitor.max_entries")))
maximum = int(str(await get_config("business.max_upload_bytes")))
if not 1 <= limit <= 100000 or not 1 <= maximum <= 104857600:
raise ValueError("Invalid directory entry or upload limit")
app.state.inventory = Inventory(root, limit)
app.state.max_upload = maximum
yield
app.state.inventory.subscribers.clear()
service = LclFastAPI(lifespan=lifespan, background_workers={"monitor": monitor})
service.add_middleware(
TrustedHostMiddleware, allowed_hosts=["127.0.0.1", "localhost", "testserver"]
)
@service.get("/api/files")
async def files(request: Request) -> dict[str, object]:
inventory: Inventory = request.app.state.inventory
return inventory.current()
Before yield, resolve/create the root, validate limits, and assign the shared
resource to app.state. LclFastAPI(lifespan=..., background_workers={...}) starts
the registered monitor only after business initialization succeeds. Configuration
chooses parameters; it never supplies executable code. The nonempty registry
requires one effective API worker, even when disabled; a larger valid count is
warned about and overridden without rewriting the configuration.
After yield, business teardown runs after background workers have stopped.
That lets routes and the monitor borrow the same inventory without owning its
lifetime. The request obtains it from request.app.state, not a new global scan.
Start the service:
lcl-fastapi serve -o config service.lclcfg
In another terminal:
curl http://127.0.0.1:18085/health
curl http://127.0.0.1:18085/api/files
lcl-fastapi status -o config service.lclcfg
Checkpoint: health is 200, status shows one API worker and the monitor, and
the inventory eventually contains files you create in watched. Initially it
may be empty until the first scan. Stop with lcl-fastapi stop -o config
service.lclcfg before editing the app; restart after each following Python step.
6. Add uploads and downloads¶
Append these routes to directory_service/app.py:
@service.put("/api/files/{name:path}", status_code=201)
async def upload(name: str, request: Request) -> dict[str, str]:
origin = request.headers.get("origin")
if origin and origin != str(request.base_url).rstrip("/"):
raise HTTPException(403, "Cross-origin writes are not allowed")
chunks = bytearray()
async for chunk in request.stream():
chunks.extend(chunk)
if len(chunks) > request.app.state.max_upload:
raise HTTPException(413, "Upload exceeds configured limit")
inventory: Inventory = request.app.state.inventory
await asyncio.to_thread(save, inventory.root, name, bytes(chunks))
return {"path": name}
@service.get("/api/files/{name:path}")
async def download(name: str, request: Request) -> Response:
inventory: Inventory = request.app.state.inventory
def checked() -> Path:
path = within(inventory.root, name)
if not path.exists() or not stat.S_ISREG(path.stat().st_mode):
raise HTTPException(404, "File not found")
return path
path = await asyncio.to_thread(checked)
return FileResponse(path, filename=path.name, media_type="application/octet-stream")
The decorators and Request, HTTPException and FileResponse retain normal
FastAPI behavior. Uploads consume a bounded raw request body; no multipart package
is required. Intentional bad input returns its explicit HTTP status, while an
unexpected bug uses lcl-fastapi's detailed error log and generic 500 response.
Restart and try the first upload/download (PowerShell: curl.exe):
curl -i -X PUT --data-binary "hello" http://127.0.0.1:18085/api/files/notes/hello.txt
curl http://127.0.0.1:18085/api/files/notes/hello.txt
Checkpoint: the upload returns 201 and the download contains hello. Repeat
the PUT and expect 409, because this service never overwrites. Missing files return
404, oversized uploads 413, and rejected paths 400. The monitor, not the upload
handler, eventually refreshes the inventory. Use curl -OJ to save an attachment.
7. Stream background changes over SSE¶
Append the event route to directory_service/app.py:
@service.get("/api/events")
async def events(request: Request) -> StreamingResponse:
inventory: Inventory = request.app.state.inventory
async def stream() -> AsyncIterator[str]:
queue: asyncio.Queue[dict[str, object]] = asyncio.Queue(maxsize=1)
inventory.subscribers.add(queue)
queue.put_nowait(inventory.current())
try:
while True:
try:
snapshot = await asyncio.wait_for(queue.get(), timeout=5)
yield (
f"id: {snapshot['revision']}\nevent: inventory\n"
f"data: {json.dumps(snapshot)}\n\n"
)
except TimeoutError:
yield ": heartbeat\n\n"
finally:
inventory.subscribers.discard(queue)
return EventStream(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Each HTTP connection registers one queue, receives the current snapshot, then
waits for publications from the background worker. The finally block removes
that subscription on disconnect or cancellation. Five-second comments keep idle
streams active; each inventory event has a revision ID and complete JSON state.
Restart, run curl -N http://127.0.0.1:18085/api/events, and edit then delete
watched/notes/hello.txt in another terminal. Checkpoint: both changes arrive
on the existing connection without a refresh. Stop curl before stopping the
service: an open stream is a live HTTP request and otherwise uses the native
graceful timeout. Reconnecting always sends current state; it does not replay a
Last-Event-ID history. A proxy must not buffer this response.
8. Build the browser UI and serve static assets¶
Create the following three files under directory_service/static. The HTML
defines the upload controls, totals, table and connection status. The CSS is only
presentation; expand its complete source to copy it.
Complete directory_service/static/index.html
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Directory Observatory</title><link rel="stylesheet" href="/static/style.css"><script src="/static/app.js" defer></script></head>
<body><header><div><div class="eyebrow">Build a service · Example 02</div><h1>Directory Observatory</h1><p>A live view of your configured directory, down to the last file.</p></div><span class="badge" id="connection">Connecting to live updates…</span></header>
<main><section class="cards" aria-label="Directory statistics"><div class="card"><small>Files</small><strong id="file-count">0</strong></div><div class="card"><small>Directories</small><strong id="dir-count">0</strong></div><div class="card"><small>Total file bytes</small><strong id="bytes">0</strong></div><div class="card"><small>Snapshot revision</small><strong id="revision">0</strong></div></section>
<section class="panel"><h2>Directory contents</h2><div class="toolbar"><input class="search" id="filter" aria-label="Filter paths" placeholder="Filter paths, including nested folders…"><button id="refresh">Refresh snapshot</button></div><div class="scroll"><table><thead><tr><th>Relative path</th><th>Kind</th><th>Bytes</th><th>Last modified</th><th>Action</th></tr></thead><tbody id="entries"></tbody></table></div><p id="empty" class="muted">Waiting for the first snapshot…</p></section>
<section class="panel"><h2>Add a file</h2><p class="muted">Files are created under the configured root. Existing files are never overwritten.</p><form id="upload"><div class="toolbar"><input id="file" type="file" required aria-label="Choose a file"><label for="destination">Relative destination</label><input class="search" id="destination" required placeholder="notes/report.txt"><button class="primary" type="submit">Upload file</button></div></form><div id="message" class="notice" role="status">Choose a file, or edit the watched directory on disk to see live updates.</div></section>
<footer>Local, trusted-directory demo · Recursive polling runs in a managed background thread · SSE sends complete snapshots and reconnects automatically.</footer></main></body></html>
Complete directory_service/static/style.css
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#182b43;background:#f4f6fa;font-size:15px}*{box-sizing:border-box}body{margin:0}header{background:#152a43;color:white;padding:26px max(5vw,20px);display:flex;justify-content:space-between;align-items:center;gap:20px}h1{font-size:27px;margin:6px 0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.55}.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:#9db7d6}header p{margin:0;color:#c5d4e4}.badge{border:1px solid #49647f;border-radius:30px;padding:7px 13px;white-space:nowrap;font-size:12px}main{max-width:1360px;margin:28px auto;padding:0 24px}.cards{display:flex;gap:16px;margin-bottom:24px}.card,.panel{border:1px solid #dce3ed;border-radius:12px;background:white;box-shadow:0 3px 10px #172b4305}.card{padding:18px 24px;flex:1}.card small{display:block;color:#64758a}.card strong{font-size:28px;display:block;margin-top:7px}.panel{padding:24px;margin-bottom:20px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px}button,.button{border:1px solid #c6d3e2;border-radius:7px;padding:9px 15px;font:inherit;cursor:pointer;background:white;color:#223952;text-decoration:none}button.primary{background:#245bba;color:white;border-color:#245bba}button:hover,.button:hover{filter:brightness(.95)}button:disabled{opacity:.4;cursor:default}input,textarea{font:inherit;border:1px solid #bfcede;border-radius:7px;padding:10px;background:#fcfdff;color:#183049}input:focus,textarea:focus,button:focus-visible,a:focus-visible{outline:3px solid #9bc5ff;outline-offset:2px}.search{flex:1;min-width:180px}label{font-weight:600;font-size:13px}table{width:100%;border-collapse:collapse;text-align:left}th{font-size:11px;color:#64758a;text-transform:uppercase;letter-spacing:.08em}td,th{padding:13px 10px;border-bottom:1px solid #e8edf3}td small,.muted{color:#64758a}.scroll{overflow:auto}.notice{padding:12px 16px;border-left:3px solid #267761;background:#ebf7f1;margin:15px 0;min-height:42px;white-space:pre-wrap}.notice.error{border-color:#b63448;background:#fff0f1;color:#8c2435}.split{display:grid;grid-template-columns:minmax(280px, .85fr) minmax(380px,1.4fr);gap:20px}textarea{width:100%;resize:vertical;font:14px/1.65 Consolas,monospace;tab-size:2}.editor label{display:block;margin:16px 0 8px}.tabs{display:flex;gap:8px;border-bottom:1px solid #dce3ed;margin-bottom:18px;padding-bottom:12px}.tabs button[aria-selected=true]{background:#e7efff;color:#214f9e;border-color:#95b4ea}pre,code{font-family:Consolas,monospace}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f3f6fa;padding:14px;border-radius:7px;line-height:1.6}.tree details{margin:6px 0 6px 17px;border-left:1px solid #d8e2ee;padding-left:10px}.tree summary{cursor:pointer;padding:5px}.tree code{font-size:12px;color:#3c649b}.graph{width:100%;min-height:180px}.step{border:1px solid #cad9eb;background:#f1f6ff;padding:18px;border-radius:8px;min-height:130px;white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 Consolas,monospace}footer{color:#6c7c90;font-size:12px;padding:12px 2px 25px}a{color:#245bba}[hidden]{display:none!important}@media(max-width:850px){.split{grid-template-columns:1fr}.cards{flex-wrap:wrap}.card{min-width:120px}header{align-items:flex-start;flex-direction:column}.panel{padding:17px}main{padding:0 14px}td,th{padding:10px 5px}}
Create directory_service/static/app.js. It subscribes to the named inventory
event, replaces displayed state, filters relative paths, and sends a selected
File directly as a PUT body. It waits for the monitor's next snapshot rather than
inventing a local revision after upload. User filenames are rendered as text.
"use strict";
const $ = id => document.getElementById(id);
let snapshot = {entries: [], errors: [], revision: 0};
function message(text, error = false) { $("message").textContent = text; $("message").classList.toggle("error", error); }
function render(data = snapshot) {
snapshot = data;
const entries = data.entries || [], files = entries.filter(e => e.kind === "file");
$("file-count").textContent = files.length;
$("dir-count").textContent = entries.length - files.length;
$("bytes").textContent = files.reduce((sum, e) => sum + e.bytes, 0).toLocaleString();
$("revision").textContent = data.revision;
const visible = entries.filter(e => e.path.toLowerCase().includes($("filter").value.toLowerCase()));
$("entries").replaceChildren();
visible.forEach(entry => {
const row = document.createElement("tr");
[entry.path, entry.kind, entry.bytes.toLocaleString(), new Date(entry.modified_ns / 1e6).toLocaleString()].forEach(value => {
const cell = row.insertCell(); cell.textContent = value;
});
const action = row.insertCell();
if (entry.kind === "file") {
const link = document.createElement("a"); link.textContent = "Download";
link.href = "/api/files/" + entry.path.split("/").map(encodeURIComponent).join("/");
action.append(link);
}
$("entries").append(row);
});
$("empty").hidden = visible.length > 0;
$("empty").textContent = entries.length ? "No paths match this filter." : "This directory is empty. Upload a file to begin.";
if (data.truncated || data.errors?.length) message([data.truncated ? "Entry limit reached: this snapshot is partial." : "", ...(data.errors || [])].filter(Boolean).join("\n"), true);
}
async function refresh() {
try { const response = await fetch("/api/files"); if (!response.ok) throw Error("Snapshot unavailable"); render(await response.json()); }
catch (error) { message(error.message, true); }
}
$("filter").addEventListener("input", () => render());
$("refresh").addEventListener("click", refresh);
$("file").addEventListener("change", () => { if ($("file").files[0]) $("destination").value = $("file").files[0].name; });
$("upload").addEventListener("submit", async event => {
event.preventDefault(); const button = event.submitter; button.disabled = true;
try {
const name = $("destination").value;
const response = await fetch("/api/files/" + name.split("/").map(encodeURIComponent).join("/"), {method: "PUT", headers: {"Content-Type": "application/octet-stream"}, body: $("file").files[0]});
const body = await response.json(); if (!response.ok) throw Error(body.detail || "Upload failed");
message("Created " + name + ". The background scan will publish its statistics shortly.");
} catch (error) { message(error.message, true); } finally { button.disabled = false; }
});
const events = new EventSource("/api/events");
events.addEventListener("open", () => { $("connection").textContent = "● Live updates connected"; });
events.addEventListener("inventory", event => render(JSON.parse(event.data)));
events.addEventListener("error", () => { $("connection").textContent = "Reconnecting…"; });
window.addEventListener("pagehide", () => events.close());
refresh();
Append the home route and static mount to directory_service/app.py:
@service.get("/", include_in_schema=False)
async def frontend() -> FileResponse:
return FileResponse(Path(__file__).parent / "static/index.html")
service.mount(
"/static", StaticFiles(directory=Path(__file__).parent / "static", html=True), name="frontend"
)
Use /static and an explicit / route. A catch-all root mount would shadow
framework health and shutdown routes registered during lifespan. Asset paths
come from the installed package's __file__, not the current shell directory.
Relative browser API URLs keep the UI on the same origin without a CORS service.
Checkpoint: restart and open http://127.0.0.1:18085/. Upload, filter, download
and edit a file outside the browser. The table must update through SSE. Confirm
/health and /docs still work, then reload the browser to get a current snapshot.
9. Operate and verify your completed service¶
Run lcl-fastapi logs -o config service.lclcfg to find actual worker log paths,
including rotated segments. logger.file.monitor inherits the default log
directory and has a dedicated named source; API request logs stay separate.
Monitor lifecycle events appear in controller logs. Use curl -i and the response
X-Request-ID to find an API call in its access log. An SSE access entry completes
when the long-lived request ends.
After closing streams and stopping, try one configuration exercise at a time:
| Change | Observe after restart |
|---|---|
Add -o background_worker.monitor.interval_seconds "LCL[2]" to serve |
Slower scanning without changing the file; the next launch without the override restores its value. |
Set background_worker.monitor.enabled: False |
File APIs still work; the inventory no longer tracks changes and the worker opens no dedicated file. |
Set an invalid interval and background_worker.monitor.auto_restart: False |
Entry failure is recorded once and stays failed. |
Set an invalid interval and background_worker.monitor.auto_restart: True |
Failed executions restart after one second and increase observed counters until stopped. |
Restore valid values. Both switches are Boolean values, not quoted strings. Normal entry returns also restart by default; complete service restart applies configuration edits, not automatic entry restart. Startup resource failure is different from an entry failure: invalid lifespan limits prevent service startup.
Create verify.py with this complete isolated check, then stop your manual server
and run python verify.py. It creates a temporary workspace, starts the installed
app, exercises nested upload/download, external modification/deletion, SSE and
shutdown, and removes its temporary files. It does not use your watched data.
Complete verify.py
"""Verify the installed tutorial application using real native service processes."""
import json
import os
import subprocess
import sys
import sysconfig
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
import psutil
HERE = Path(__file__).resolve().parent
CLI = Path(sysconfig.get_path("scripts")) / (
"lcl-fastapi.exe" if os.name == "nt" else "lcl-fastapi"
)
def http(
origin: str,
path: str,
method: str = "GET",
data: bytes | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, bytes]:
request = Request(origin + path, method=method, data=data, headers=headers or {})
try:
response = urlopen(request, timeout=8)
except HTTPError as error:
response = error
with response:
return response.status, response.read()
def json_http(origin: str, path: str, method: str = "GET", data: object = None) -> tuple[int, Any]:
status, body = http(
origin,
path,
method,
None if data is None else json.dumps(data).encode(),
{"Content-Type": "application/json"},
)
return status, json.loads(body)
def main() -> None:
with TemporaryDirectory() as directory:
root = Path(directory)
source = HERE.joinpath("service.lclcfg").read_text(encoding="utf-8")
config = root / "service.lclcfg"
config.write_text(source, encoding="utf-8")
origin = ORIGIN
def command(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[str(CLI), *args, "-o", "config", str(config)],
cwd=root,
capture_output=True,
text=True,
timeout=45,
check=True,
)
with (root / "console.log").open("w+", encoding="utf-8") as output:
process = subprocess.Popen(
[str(CLI), "serve", "-o", "config", str(config)],
cwd=root,
stdout=output,
stderr=subprocess.STDOUT,
)
try:
deadline = time.monotonic() + 30
while True:
assert process.poll() is None, (root / "console.log").read_text(
encoding="utf-8"
)
try:
if http(origin, "/health")[0] == 200:
break
except URLError, TimeoutError:
pass
assert time.monotonic() < deadline, "Service startup timed out"
time.sleep(0.1)
assert http(origin, "/")[0] == 200
for asset in ["/static/app.js", "/static/style.css"]:
assert http(origin, asset)[0] == 200
verify(origin, root)
status = json.loads(command("status").stdout)
assert status["service"]["configured_workers"] == 1
except BaseException:
print((root / "console.log").read_text(encoding="utf-8"), file=sys.stderr)
for log in sorted(root.glob("logs/*.log*")):
print(log.read_text(encoding="utf-8"), file=sys.stderr)
raise
finally:
if process.poll() is None:
owner = psutil.Process(process.pid)
try:
command("stop")
except BaseException:
for child in reversed(owner.children(recursive=True)):
child.kill()
owner.kill()
process.wait(timeout=10)
raise
process.wait(timeout=45)
assert process.returncode == 0, (root / "console.log").read_text(encoding="utf-8")
assert json.loads(command("status").stdout)["status"] == "STOPPED"
print("PASS " + HERE.name + ": installed static UI, native APIs, isolation and cleanup")
ORIGIN = "http://127.0.0.1:18085"
def verify(origin: str, root: Path) -> None:
with urlopen(origin + "/api/events", timeout=8) as stream:
def event() -> dict[str, Any]:
for _ in range(100):
line = stream.readline().decode()
assert line, "SSE connection ended before the expected filesystem update"
if line.startswith("data: "):
value: dict[str, Any] = json.loads(line[6:])
return value
raise AssertionError("No SSE snapshot received")
event()
assert http(origin, "/api/files/nested/hello.txt", "PUT", b"hello")[0] == 201
deadline = time.monotonic() + 8
while True:
value = event()
if any(row["path"] == "nested/hello.txt" for row in value["entries"]):
break
assert time.monotonic() < deadline
assert http(origin, "/api/files/nested/hello.txt") == (200, b"hello")
assert http(origin, "/api/files/nested/hello.txt", "PUT", b"other")[0] == 409
assert http(origin, "/api/files/%2e%2e/escape.txt", "PUT", b"bad")[0] == 400
assert (
http(
origin,
"/api/files/rejected.txt",
"PUT",
b"bad",
{"Origin": "https://other.example"},
)[0]
== 403
)
assert not (root / "escape.txt").exists()
(root / "watched/nested/hello.txt").write_bytes(b"changed on disk")
while True:
value = event()
if any(
row["path"] == "nested/hello.txt" and row["bytes"] == 15 for row in value["entries"]
):
break
assert time.monotonic() < deadline
(root / "watched/nested/hello.txt").unlink()
while any(row["path"] == "nested/hello.txt" for row in event()["entries"]):
assert time.monotonic() < deadline
assert json_http(origin, "/api/files")[0] == 200
if __name__ == "__main__":
main()
The printed Python, configuration and static files form the complete installable
project. Keep them under version control; exclude .venv, run, logs and local
watched data. CI reconstructs this project from the guide's exact code and checks
its native service, as well as installing a built framework wheel on both platforms.
Filesystem boundary¶
Use a trusted dedicated root. Links/junctions and special files are not served, but checks assume no hostile local process swaps entries between validation and I/O. This is not an authenticated file-sharing service or a race-proof filesystem sandbox. Scans cost O(entries visited) up to the cap, are not transactional, and can report partial state/errors. Upload memory is bounded; a failed disk write can leave an incomplete new file for the operator to remove. Add reviewed storage and authentication controls before exposing it outside your machine.