Compare commits

..
10 Commits
7 changed files with 644 additions and 328 deletions
+11 -2
View File
@@ -63,7 +63,16 @@ Options:
In development, you access the Vite dev server at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Ports and hosts of Vite and FastAPI are configurable by `devserver.py` arguments.
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument.
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument with defaults set in `__main__.py`
## Main CLI
If your project didn't already have `__main__.py`, we create one that runs the FastAPI app with richer configuration than what the FastAPI CLI offers. Running your module starts it in production mode, and optionally host:port may be given as argument to specify where it listens.
If you are running behind a reverse proxy like [Caddy](https://caddyserver.com/) on localhost, your app will trust the proxy headers it sends. However, if you need to configure another proxy host or IP, set `FORWARDED_ALLOW_IPS` env variable before running the server.
The devserver script depends on this CLI entry for running the backend. You will have to modify the `devserver.py` script if your app has its own incompatible main module. Note that we set FastAPI debug mode and Uvicorn reload when configured via `FASTAPI_VUE_BACKEND_URL` env variable (set by `devserver.py`), while for normal production use these stay disabled. The same variable also controls static files serving (disabled in dev mode).
## Vite Plugin Configuration
@@ -108,7 +117,7 @@ my-app/
└── pyproject.toml
```
The project directory tree looks roughly like this after project creation or patching. The script finds your existing fastpi app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
The project directory tree looks roughly like this after project creation or patching. The script finds your existing app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
## Development Workflow
+539 -228
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"tomli-w>=1.0.0",
"tomlkit>=0.12.0",
]
[project.urls]
+45 -21
View File
@@ -1,30 +1,54 @@
import sys
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse
import asyncio
import os
import uvicorn
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from .APP_MODULE import APP_VAR
DEFAULT_PORT = 5080
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
if proxy:
conf["proxy_headers"] = True
conf["forwarded_allow_ips"] = proxy
if devmode:
conf["reload"] = True
conf["reload_dirs"] = ["MODULE_NAME"]
APP_VAR.debug = True
if len(endpoints) > 1:
# Run separate servers for multiple endpoints
async def serve_all():
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(Server(Config(**conf, **ep)).serve())
asyncio.run(serve_all())
else:
uvicorn.run(**conf, **endpoints[0])
def main():
"""Run the FastAPI application using uvicorn."""
if len(sys.argv) > 1:
endpoint = sys.argv[1]
if ":" in endpoint:
host, port = endpoint.rsplit(":", 1)
host = host or "localhost"
port = int(port)
else:
host = "localhost"
port = int(endpoint)
else:
host = "localhost"
port = 5080
uvicorn.run(
"MODULE_NAME.app:app",
host=host,
port=port,
log_level="info",
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
parser.add_argument(
"endpoint",
nargs="?",
help=(
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
),
)
args = parser.parse_args()
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
run_server(endpoints, proxy=proxy, devmode=devmode)
if __name__ == "__main__":
+2 -4
View File
@@ -5,9 +5,7 @@ from fastapi import FastAPI
from fastapi_vue import Frontend
# Vue Frontend static files
frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
)
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
@asynccontextmanager
@@ -29,5 +27,5 @@ async def health_check():
return {"status": "ok"}
# Final catch-all route for frontend files (keep at end of file)
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
frontend.route(app, "/")
+44 -69
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
Usage:
@@ -16,17 +17,15 @@ Environment:
import argparse
import asyncio
import contextlib
import ipaddress
import os
from pathlib import Path
from sys import stderr
from urllib.parse import urlparse
import httpx
from fastapi_vue.hostutil import parse_endpoint
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_HOST = "localhost"
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
@@ -45,45 +44,8 @@ BUN_BUG = """\
"""
def parse_endpoint(
value: str | None, default_port: int = DEFAULT_VITE_PORT
) -> tuple[str | None, int, bool]:
"""Parse an endpoint for Vite or backend.
Returns (host, port, all_ifaces).
"""
if not value:
return DEFAULT_HOST, default_port, False
# Port only (numeric) -> localhost:port
if value.isdigit():
return DEFAULT_HOST, int(value), False
# Leading colon :port -> bind all interfaces
if value.startswith(":") and value != ":":
port_part = value[1:]
if not port_part.isdigit():
raise SystemExit(f"Invalid port in '{value}'")
return None, int(port_part), True
# Unbracketed IPv6 (cannot safely contain a port)
if value.count(":") > 1 and not value.startswith("["):
try:
ipaddress.IPv6Address(value)
except ValueError as e:
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
return value, default_port, False
# Use urllib.parse for everything else
parsed = urlparse(f"//{value}")
host = parsed.hostname or DEFAULT_HOST
port = parsed.port or default_port
return host, port, False
def resolve_frontend_tools(
vite_host: str | None, vite_port: int, all_ifaces: bool
vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
@@ -114,12 +76,15 @@ def resolve_frontend_tools(
}
install_cmd = [tool, *install_args[name]]
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
dev_cmd = [
tool,
*dev_args[name],
"--clearScreen=false",
f"--port={vite_port}",
]
if all_ifaces:
dev_cmd.append("--host")
elif vite_host:
dev_cmd.extend(["--host", vite_host])
if name == "bun":
stderr.write(BUN_BUG)
@@ -127,13 +92,15 @@ def resolve_frontend_tools(
return install_cmd, dev_cmd, name
async def wait_for_backend(backend_host: str, backend_port: int):
async def wait_for_backend(host: str, port: int):
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
url = f"http://{host}:{port}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
await client.get(url, timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
@@ -163,23 +130,29 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
async def run_devserver(
vite_host: str | None,
vite_port: int,
all_ifaces: bool,
backend_host: str,
backend_port: int,
) -> None:
"""Run the development server with install, backend, and frontend."""
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
vite_host, vite_port, all_ifaces
)
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
# Tell the backend where the Vite dev server is
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
f"http://{vite_host or 'localhost'}:{vite_port}"
)
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
# Tell Vite where the backend is (for proxying /api requests)
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload",
]
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
@@ -197,15 +170,6 @@ async def run_devserver(
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload",
]
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
@@ -287,15 +251,26 @@ def main():
)
args = parser.parse_args()
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
# Backend host defaults to localhost (never None)
backend_host = backend_host or DEFAULT_HOST
# parse_endpoint returns list of dicts with host/port or uds keys
# Multiple entries means bind all interfaces (IPv4 + IPv6)
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
# Vite doesn't support unix sockets
if "uds" in vite_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
raise SystemExit(1)
if "uds" in backend_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
raise SystemExit(1)
vite_port = vite_endpoints[0]["port"]
all_ifaces = len(vite_endpoints) > 1
backend_host = backend_endpoints[0]["host"]
backend_port = backend_endpoints[0]["port"]
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
)
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
if __name__ == "__main__":
+2 -3
View File
@@ -51,10 +51,9 @@ def find_build_tool():
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Use build-only for deno to avoid npm-run-all2 issues with run-p
# (run-p tries to spawn npm which doesn't exist in deno)
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("task", "build-only"),
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}