Compare commits

...
10 Commits
6 changed files with 657 additions and 345 deletions
+25 -23
View File
@@ -1,6 +1,6 @@
# fastapi-vue-setup
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (npm recommended due to bugs in deno and bun).
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (node is recommended due to bugs in deno and bun).
## Features
@@ -61,17 +61,18 @@ Options:
## Port Configuration
The tool uses three distinct ports:
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.
| Port | Purpose | Used by |
| ---- | --------------------- | ------------------------------ |
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
| 5180 | FastAPI in dev mode | uvicorn via devserver.py |
| 5080 | Production server | `uv run my-app` |
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`
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Configurable with `devserver.py` arguments.
## 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).
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument.
## Vite Plugin Configuration
@@ -96,26 +97,27 @@ The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localho
## Project Structure
After patching, your project will have:
```
my-app/
├── frontend/ # Vue application
├── frontend/ # Vue application
│ ├── src/
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
│ ├── vite-plugin-fastapi.js
│ ├── vite.config.js
│ └── package.json
├── my_app/ # Python package
├── my_app/ # Python module (files included in sdist)
│ ├── __init__.py
│ ├── __main__.py # CLI entry point
│ ├── app.py # FastAPI application
│ └── frontend-build/ # Built frontend (gitignored)
│ ├── __main__.py # CLI entrypoint
│ ├── app.py # FastAPI application
│ └── frontend-build/ # Built frontend (gitignored)
├── scripts/
│ ├── devserver.py # Development server
│ └── fastapi-vue/ # Build utilities
└── pyproject.toml # Project configuration
│ ├── devserver.py # CLI dev server (only in source tree)
│ └── fastapi-vue/
│ ├── build-frontend.py
│ └── util.py
└── pyproject.toml
```
The script finds your existing fastpi app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
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
@@ -130,6 +132,6 @@ uv build
uv run my-app
```
# Frontend serving
## Frontend serving
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend file. Refer to that package's documentation for further configuration.
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend files. Refer to that package's documentation for further configuration.
+540 -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__":
+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"),
}