diff --git a/README.md b/README.md index 2ae0619..e2e26d0 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,21 @@ siteharbor Open `http://127.0.0.1:8787`. +The `siteharbor` command runs the FastAPI application with Uvicorn. Override the bind port and +start multiple Uvicorn worker processes when needed: + +```powershell +siteharbor --port 9000 --workers 2 +``` + +`--port` accepts ports from 1 through 65535 and overrides `SITEHARBOR_PORT`. `--workers` defaults +to 1 and controls Uvicorn processes, not the capture-worker count configured by +`SITEHARBOR_WORKER_CONCURRENCY`. + To use Uvicorn directly: ```powershell -python -m uvicorn app.main:app --host 127.0.0.1 --port 8787 +python -m uvicorn app.main:app --host 127.0.0.1 --port 8787 --workers 2 ``` The default bind address is loopback. Do not expose SiteHarbor directly to the public Internet without authentication, a network egress policy, rate limits, and operational controls. @@ -56,9 +67,9 @@ All configuration uses environment variables prefixed with `SITEHARBOR_`. | Variable | Default | Purpose | | --- | --- | --- | | `SITEHARBOR_HOST` | `127.0.0.1` | Bind address used by `siteharbor`. | -| `SITEHARBOR_PORT` | `8787` | Bind port used by `siteharbor`. | +| `SITEHARBOR_PORT` | `8787` | Default bind port used by `siteharbor`; overridden by `--port`. | | `SITEHARBOR_DATA_DIR` | `./data` | SQLite database, work directories, artifacts, and reports. | -| `SITEHARBOR_WORKER_CONCURRENCY` | `1` | Capture workers per application process. | +| `SITEHARBOR_WORKER_CONCURRENCY` | `1` | Capture workers per Uvicorn process; separate from `--workers`. | | `SITEHARBOR_FETCH_CONCURRENCY` | `6` | Maximum parallel fetches a visitor may select for one capture. | | `SITEHARBOR_LEASE_SECONDS` | `120` | SQLite worker lease duration; values below 15 seconds are rejected. | | `SITEHARBOR_ALLOW_PRIVATE_NETWORKS` | `false` | Development-only override that permits loopback and private hosts. | diff --git a/app/main.py b/app/main.py index a0f2dfc..f27487b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import asyncio import json import re @@ -387,8 +388,51 @@ def _safe_data_file(path_value: str, root: Path) -> Path | None: return None -def run() -> None: - uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=False) +def _positive_integer(value: str) -> int: + try: + number = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("must be a whole number") from error + if number < 1: + raise argparse.ArgumentTypeError("must be at least 1") + return number + + +def _port_number(value: str) -> int: + port = _positive_integer(value) + if port > 65_535: + raise argparse.ArgumentTypeError("must be between 1 and 65535") + return port + + +def _parse_server_arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the SiteHarbor FastAPI server with Uvicorn.") + parser.add_argument( + "--port", + type=_port_number, + default=settings.port, + metavar="PORT", + help=f"bind port (default: SITEHARBOR_PORT or {settings.port})", + ) + parser.add_argument( + "--workers", + type=_positive_integer, + default=1, + metavar="WORKERS", + help="number of Uvicorn worker processes (default: 1)", + ) + return parser.parse_args(argv) + + +def run(argv: list[str] | None = None) -> None: + arguments = _parse_server_arguments(argv) + uvicorn.run( + "app.main:app", + host=settings.host, + port=arguments.port, + workers=arguments.workers, + reload=False, + ) if __name__ == "__main__": diff --git a/tests/test_main.py b/tests/test_main.py index 17d319f..1bcce9d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -65,3 +65,26 @@ def test_capture_routes_are_scoped_to_the_browser_session(tmp_path, monkeypatch) stats = client.get("/api/stats") assert stats.status_code == 200 assert stats.json()["active_crawls"] == 1 + + +def test_run_forwards_server_options_to_uvicorn(monkeypatch) -> None: + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def fake_run(*args: object, **kwargs: object) -> None: + calls.append((args, kwargs)) + + monkeypatch.setattr(main.uvicorn, "run", fake_run) + + main.run(["--port", "9123", "--workers", "2"]) + + assert calls == [ + ( + ("app.main:app",), + { + "host": settings.host, + "port": 9123, + "workers": 2, + "reload": False, + }, + ) + ]