Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2983a62a0 | ||
|
|
d9323f34e1 | ||
|
|
a4379094db | ||
|
|
52ec232ce4 |
@@ -1,15 +1,13 @@
|
||||
# fastapi-vue-setup
|
||||
|
||||
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. Only building the package needs JS runtime and Vue. Additionally, a devmode setup using Vite dev server with hot auto reloads is available via the scripts/devserver.py script (only intended to be used on the source repo, not included in installed package).
|
||||
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).
|
||||
|
||||
## Features
|
||||
|
||||
- **No JavaScript**: Your Python package can be installed and used without any JS runtime
|
||||
- **Create new projects** with `uv init` + `create-vue` (interactive)
|
||||
- **Patch existing projects** with build hooks and dev server scripts
|
||||
- **Integrated build system**: Vue frontend builds into Python package during `uv build`
|
||||
- **Development server**: Single command runs Vite + FastAPI with hot-reload
|
||||
- **Optimized static serving**: zstd compression, ETag caching, SPA support
|
||||
- **Optimized static serving**: Caching, zstd compression and SPA support
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -28,41 +26,39 @@ uvx fastapi-vue-setup my-app
|
||||
|
||||
## Usage
|
||||
|
||||
The script may be used to create an all new project folder or to patch or update an existing project to use this framework. It autodetects the project folder given and performs the appropriate actions.
|
||||
|
||||
### Create a new project
|
||||
|
||||
```sh
|
||||
fastapi-vue-setup new my-app
|
||||
fastapi-vue-setup my-app
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Run `uv init my-app`
|
||||
2. Run `npm create vue@latest frontend` (interactive)
|
||||
2. Run `create-vue frontend` (interactive - choose your Vue options)
|
||||
3. Patch the project with FastAPI integration
|
||||
4. Install dependencies via `uv add`
|
||||
|
||||
### Patch an existing project
|
||||
|
||||
```bash
|
||||
fastapi-vue-setup patch /path/to/project
|
||||
You should have your pyproject.toml at the current working directory, and Vue with its package.json under `frontend/`. If either one is missing, new applications will be initialised. Otherwise we only patch what can be patched without breaking your existing projects.
|
||||
|
||||
```sh
|
||||
fastapi-vue-setup .
|
||||
```
|
||||
|
||||
### CLI Options
|
||||
|
||||
```
|
||||
fastapi-vue-setup [project-dir] [options]
|
||||
|
||||
Options:
|
||||
|
||||
- `--module-name NAME`: Python module name (auto-detected from pyproject.toml)
|
||||
- `--vite-port PORT`: Vite dev server port (default: 5173)
|
||||
- `--backend-port PORT`: Backend API port in dev mode (default: 5174)
|
||||
- `--prod-port PORT`: Production server port (default: 8000)
|
||||
- `--force`: Overwrite existing files
|
||||
- `--dry-run`: Preview changes without modifying files
|
||||
|
||||
### Update an existing project
|
||||
|
||||
```bash
|
||||
fastapi-vue-setup update /path/to/project
|
||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
||||
--dry-run Preview changes without modifying files
|
||||
```
|
||||
|
||||
Same as `patch --force` - overwrites template files with latest versions.
|
||||
|
||||
## Port Configuration
|
||||
|
||||
The tool uses three distinct ports:
|
||||
@@ -70,12 +66,33 @@ The tool uses three distinct ports:
|
||||
| Port | Purpose | Used by |
|
||||
| ---- | --------------------- | ------------------------------ |
|
||||
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
|
||||
| 5174 | FastAPI in dev mode | uvicorn via devserver.py |
|
||||
| 8000 | Production server | `uv run my-app` |
|
||||
| 5180 | FastAPI in dev mode | uvicorn via devserver.py |
|
||||
| 5080 | Production server | `uv run my-app` |
|
||||
|
||||
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174.
|
||||
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Configurable with `devserver.py` arguments.
|
||||
|
||||
In production, FastAPI serves both the API and static files from the same port (8000).
|
||||
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument.
|
||||
|
||||
## Vite Plugin Configuration
|
||||
|
||||
The `fastapiVue()` plugin in `vite.config.ts` accepts options to customize proxy behavior:
|
||||
|
||||
```js
|
||||
import fastapiVue from "./vite-plugin-fastapi.js";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// Default: proxies only /api
|
||||
fastapiVue(),
|
||||
|
||||
// Or specify custom paths to proxy to backend
|
||||
fastapiVue({ paths: ["/api", "/auth", "/ws"] }),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localhost:5180`) to determine where to proxy requests. This is set automatically by `devserver.py`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -83,7 +100,7 @@ After patching, your project will have:
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── frontend/ # Vue.js application
|
||||
├── frontend/ # Vue application
|
||||
│ ├── src/
|
||||
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
|
||||
│ └── package.json
|
||||
@@ -98,7 +115,7 @@ my-app/
|
||||
└── pyproject.toml # Project configuration
|
||||
```
|
||||
|
||||
The script finds your existing fastpi app module (even if not named app.py) and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
|
||||
The script finds your existing fastpi app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -113,12 +130,6 @@ uv build
|
||||
uv run my-app
|
||||
```
|
||||
|
||||
## Static File Serving
|
||||
# Frontend serving
|
||||
|
||||
The `Frontend` class provides:
|
||||
|
||||
- **Automatic zstd compression** at level 18
|
||||
- **Smart caching**: Content-hashed assets get `immutable` cache headers
|
||||
- **ETag support**: 304 Not Modified responses for cached content
|
||||
- **SPA routing**: Falls back to index.html for client-side routes
|
||||
- **Favicon handling**: Automatic /favicon.ico from hashed assets
|
||||
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.
|
||||
|
||||
+47
-101
@@ -7,12 +7,7 @@ Usage:
|
||||
|
||||
Options:
|
||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
||||
--vite-port PORT Vite dev server port (default: 5173)
|
||||
--backend-port PORT Backend API port in dev mode (default: 5180)
|
||||
--prod-port PORT Production server port (default: 5080)
|
||||
--dry-run Show what would be done without making changes
|
||||
|
||||
Port options update existing files when specified, allowing reconfiguration.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -25,11 +20,6 @@ from pathlib import Path
|
||||
|
||||
import tomli_w
|
||||
|
||||
# Port configuration (keeping them distinct)
|
||||
DEFAULT_VITE_PORT = 5173 # Vite dev server (frontend HMR)
|
||||
DEFAULT_BACKEND_PORT = 5180 # FastAPI in dev mode (Vite proxies /api here)
|
||||
DEFAULT_PROD_PORT = 5080 # Production server (static files served by FastAPI)
|
||||
|
||||
# Template directory
|
||||
TEMPLATE_DIR = Path(__file__).parent / "template"
|
||||
|
||||
@@ -38,8 +28,8 @@ PYPROJECT_ADDITIONS = {
|
||||
"tool": {
|
||||
"hatch": {
|
||||
"build": {
|
||||
"packages": ["{{MODULE_NAME}}"],
|
||||
"artifacts": ["{{MODULE_NAME}}/frontend-build"],
|
||||
"packages": ["MODULE_NAME"],
|
||||
"artifacts": ["MODULE_NAME/frontend-build"],
|
||||
"targets": {
|
||||
"sdist": {
|
||||
"hooks": {
|
||||
@@ -123,10 +113,10 @@ def _find_app_in_file(path: Path) -> str | None:
|
||||
|
||||
|
||||
def render_template(template: str, **kwargs) -> str:
|
||||
"""Simple template rendering with {{KEY}} placeholders."""
|
||||
"""Simple template rendering replacing KEY with value."""
|
||||
result = template
|
||||
for key, value in kwargs.items():
|
||||
result = result.replace(f"{{{{{key}}}}}", str(value))
|
||||
result = result.replace(key, value)
|
||||
return result
|
||||
|
||||
|
||||
@@ -227,7 +217,7 @@ frontend = Frontend(
|
||||
|
||||
if not lifespan_patched:
|
||||
# Check if they're using deprecated on_event
|
||||
if "@" + app_var + ".on_event" in content or f"@{app_var}.on_event" in content:
|
||||
if f"@{app_var}.on_event" in content:
|
||||
print()
|
||||
print("⚠️ Your app uses the deprecated @app.on_event decorator.")
|
||||
print(" Please migrate to the lifespan pattern and add:")
|
||||
@@ -246,8 +236,6 @@ frontend = Frontend(
|
||||
def patch_vite_config(
|
||||
path: Path,
|
||||
module_name: str,
|
||||
backend_port: int,
|
||||
vite_port: int,
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
|
||||
@@ -319,7 +307,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
||||
"""
|
||||
# Find the file to patch - prefer HelloWorld.vue, fall back to App.vue
|
||||
target_file = None
|
||||
|
||||
|
||||
# Try HelloWorld.vue first (full demo app)
|
||||
hello_world = frontend_dir / "src" / "components" / "HelloWorld.vue"
|
||||
if hello_world.exists():
|
||||
@@ -329,7 +317,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
||||
for path in frontend_dir.glob("src/**/HelloWorld.vue"):
|
||||
target_file = path
|
||||
break
|
||||
|
||||
|
||||
# Fall back to App.vue (minimal app)
|
||||
if target_file is None:
|
||||
app_vue = frontend_dir / "src" / "App.vue"
|
||||
@@ -356,7 +344,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
||||
|
||||
# Build the script content based on JS/TS
|
||||
if is_typescript:
|
||||
script_addition = '''
|
||||
script_addition = """
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
|
||||
@@ -369,9 +357,9 @@ onMounted(async () => {
|
||||
backendStatus.value = 'error'
|
||||
}
|
||||
})
|
||||
'''
|
||||
"""
|
||||
else:
|
||||
script_addition = '''
|
||||
script_addition = """
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const backendStatus = ref('checking')
|
||||
@@ -384,29 +372,15 @@ onMounted(async () => {
|
||||
backendStatus.value = 'error'
|
||||
}
|
||||
})
|
||||
'''
|
||||
"""
|
||||
|
||||
# Template addition - inline status indicator (no wrapper, fits in existing text)
|
||||
status_span = '''<span class="backend-status">
|
||||
— FastAPI backend:
|
||||
# Template addition - inline status indicator using emoji for colors
|
||||
status_span = """<span style="white-space: nowrap">
|
||||
— FastAPI:
|
||||
<span v-if="backendStatus === 'checking'">⏳</span>
|
||||
<span v-else-if="backendStatus === 'connected'" class="connected">✓ connected</span>
|
||||
<span v-else class="error">✗ not reachable</span>
|
||||
</span>'''
|
||||
|
||||
style_addition = '''
|
||||
.backend-status {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backend-status .connected {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
|
||||
.backend-status .error {
|
||||
color: hsla(0, 100%, 50%, 1);
|
||||
}
|
||||
'''
|
||||
<span v-else-if="backendStatus === 'connected'">✅</span>
|
||||
<span v-else>❌ not reachable</span>
|
||||
</span>"""
|
||||
|
||||
# Insert script addition before </script>
|
||||
script_end_match = re.search(r"</script>", content)
|
||||
@@ -421,7 +395,9 @@ onMounted(async () => {
|
||||
# Insert before closing </h3>
|
||||
h3_close = content.find("</h3>")
|
||||
if h3_close != -1:
|
||||
content = content[:h3_close] + "\n " + status_span + "\n " + content[h3_close:]
|
||||
content = (
|
||||
f"{content[:h3_close]}\n {status_span}\n {content[h3_close:]}"
|
||||
)
|
||||
else:
|
||||
# Minimal App.vue - insert before the last </p> before </template>
|
||||
template_end = content.find("</template>")
|
||||
@@ -429,12 +405,7 @@ onMounted(async () => {
|
||||
# Find last </p> before </template>
|
||||
last_p = content.rfind("</p>", 0, template_end)
|
||||
if last_p != -1:
|
||||
content = content[:last_p] + "\n " + status_span + "\n " + content[last_p:]
|
||||
|
||||
# Insert style addition before </style>
|
||||
style_end = content.rfind("</style>")
|
||||
if style_end != -1:
|
||||
content = content[:style_end] + style_addition + content[style_end:]
|
||||
content = f"{content[:last_p]}\n {status_span}\n {content[last_p:]}"
|
||||
|
||||
target_file.write_text(content)
|
||||
print(f"✅ Patched {target_file}")
|
||||
@@ -442,7 +413,11 @@ onMounted(async () => {
|
||||
|
||||
|
||||
def write_file(
|
||||
path: Path, content: str, overwrite: bool = True, dry_run: bool = False
|
||||
path: Path,
|
||||
content: str,
|
||||
overwrite: bool = True,
|
||||
dry_run: bool = False,
|
||||
executable: bool = False,
|
||||
) -> bool:
|
||||
"""Write content to a file, handling existing files and dry-run."""
|
||||
exists = path.exists()
|
||||
@@ -457,6 +432,8 @@ def write_file(
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
if executable and sys.platform != "win32":
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
action = "Updated" if exists else "Created"
|
||||
print(f"✅ {action} {path}")
|
||||
return True
|
||||
@@ -488,10 +465,10 @@ def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
|
||||
|
||||
hatch_build = additions["tool"]["hatch"]["build"]
|
||||
result["tool"]["hatch"]["build"]["packages"] = [
|
||||
p.replace("{{MODULE_NAME}}", module_name) for p in hatch_build["packages"]
|
||||
p.replace("MODULE_NAME", module_name) for p in hatch_build["packages"]
|
||||
]
|
||||
result["tool"]["hatch"]["build"]["artifacts"] = [
|
||||
a.replace("{{MODULE_NAME}}", module_name) for a in hatch_build["artifacts"]
|
||||
a.replace("MODULE_NAME", module_name) for a in hatch_build["artifacts"]
|
||||
]
|
||||
result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"]
|
||||
|
||||
@@ -573,9 +550,12 @@ def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
|
||||
|
||||
|
||||
def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
|
||||
"""Ensure frontend directory exists, run create-vue if needed."""
|
||||
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
||||
frontend_dir = project_dir / "frontend"
|
||||
if frontend_dir.exists():
|
||||
package_json = frontend_dir / "package.json"
|
||||
|
||||
# Check for package.json, not just directory existence (empty dir shouldn't count)
|
||||
if package_json.exists():
|
||||
return True
|
||||
|
||||
# Find JS runtime
|
||||
@@ -632,13 +612,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
project_dir = project_dir.resolve()
|
||||
dry_run = args.dry_run
|
||||
|
||||
# Check if port options were explicitly specified (for updating existing files)
|
||||
ports_specified = (
|
||||
args.vite_port != DEFAULT_VITE_PORT
|
||||
or args.backend_port != DEFAULT_BACKEND_PORT
|
||||
or args.prod_port != DEFAULT_PROD_PORT
|
||||
)
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
if not project_dir.exists():
|
||||
if dry_run:
|
||||
@@ -667,24 +640,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
module_name = project_dir.name.replace("-", "_")
|
||||
print(f"📦 Using module name from directory: {module_name}")
|
||||
|
||||
# Ports
|
||||
vite_port = args.vite_port
|
||||
backend_port = args.backend_port
|
||||
prod_port = args.prod_port
|
||||
|
||||
# Title for templates
|
||||
project_title = module_name.replace("_", " ").title()
|
||||
|
||||
print(f"📦 Module: {module_name}")
|
||||
print(f"🔌 Ports: Vite={vite_port}, Backend(dev)={backend_port}, Prod={prod_port}")
|
||||
|
||||
# Template variables
|
||||
tpl_vars = {
|
||||
"MODULE_NAME": module_name,
|
||||
"PROJECT_TITLE": project_title,
|
||||
"VITE_PORT": vite_port,
|
||||
"BACKEND_PORT": backend_port,
|
||||
"PROD_PORT": prod_port,
|
||||
}
|
||||
|
||||
module_dir = project_dir / module_name
|
||||
@@ -725,7 +689,14 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
for dest_path, template_path in script_files:
|
||||
template = load_template(template_path)
|
||||
content = render_template(template, **tpl_vars)
|
||||
write_file(dest_path, content, overwrite=True, dry_run=dry_run)
|
||||
is_executable = dest_path.name == "devserver.py"
|
||||
write_file(
|
||||
dest_path,
|
||||
content,
|
||||
overwrite=True,
|
||||
dry_run=dry_run,
|
||||
executable=is_executable,
|
||||
)
|
||||
|
||||
# === Handle app module ===
|
||||
if app_file:
|
||||
@@ -748,12 +719,10 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
# === Handle __main__.py ===
|
||||
main_file = module_dir / "__main__.py"
|
||||
if not main_file.exists() or ports_specified:
|
||||
if not main_file.exists():
|
||||
template = load_template("backend/__main__.py")
|
||||
content = render_template(template, **tpl_vars)
|
||||
write_file(main_file, content, overwrite=True, dry_run=dry_run)
|
||||
else:
|
||||
print(f"⚠️ Skipping {main_file} (exists, use --prod-port to update)")
|
||||
write_file(main_file, content, overwrite=False, dry_run=dry_run)
|
||||
|
||||
# === Update vite.config.js/ts ===
|
||||
frontend_dir = project_dir / "frontend"
|
||||
@@ -769,13 +738,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
vite_config_js = frontend_dir / "vite.config.js"
|
||||
|
||||
if vite_config_ts.exists():
|
||||
patch_vite_config(
|
||||
vite_config_ts, module_name, backend_port, vite_port, dry_run
|
||||
)
|
||||
patch_vite_config(vite_config_ts, module_name, dry_run)
|
||||
elif vite_config_js.exists():
|
||||
patch_vite_config(
|
||||
vite_config_js, module_name, backend_port, vite_port, dry_run
|
||||
)
|
||||
patch_vite_config(vite_config_js, module_name, dry_run)
|
||||
else:
|
||||
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
|
||||
print(" Run create-vue first to generate a Vite config to patch.")
|
||||
@@ -904,7 +869,6 @@ Examples:
|
||||
fastapi-vue-setup my-new-project Create a new project from scratch
|
||||
fastapi-vue-setup . Set up integration in current directory
|
||||
fastapi-vue-setup . --dry-run Preview what would be done
|
||||
fastapi-vue-setup . --vite-port 3000 Update port configuration
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -914,24 +878,6 @@ Examples:
|
||||
help="Project directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
||||
parser.add_argument(
|
||||
"--vite-port",
|
||||
type=int,
|
||||
default=DEFAULT_VITE_PORT,
|
||||
help=f"Vite dev server port (default: {DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend-port",
|
||||
type=int,
|
||||
default=DEFAULT_BACKEND_PORT,
|
||||
help=f"Backend API port in dev mode (default: {DEFAULT_BACKEND_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prod-port",
|
||||
type=int,
|
||||
default=DEFAULT_PROD_PORT,
|
||||
help=f"Production server port (default: {DEFAULT_PROD_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be done"
|
||||
)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""{{PROJECT_TITLE}} package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Entry point for the application."""
|
||||
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the FastAPI application using uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
endpoint = sys.argv[1]
|
||||
@@ -18,10 +17,10 @@ def main():
|
||||
port = int(endpoint)
|
||||
else:
|
||||
host = "localhost"
|
||||
port = {{PROD_PORT}}
|
||||
port = 5080
|
||||
|
||||
uvicorn.run(
|
||||
"{{MODULE_NAME}}.app:app",
|
||||
"MODULE_NAME.app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
|
||||
+8
-15
@@ -1,17 +1,12 @@
|
||||
"""Main FastAPI application."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
# Frontend static file server - configure options here
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent / "frontend-build",
|
||||
spa=True,
|
||||
favicon="/assets/favicon",
|
||||
cached=["/assets/"],
|
||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
||||
)
|
||||
|
||||
|
||||
@@ -22,19 +17,17 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="{{PROJECT_TITLE}}", lifespan=lifespan)
|
||||
app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan)
|
||||
|
||||
|
||||
# Add API routes here...
|
||||
|
||||
|
||||
# Health check endpoint for the Vue demo app to verify the backend is running
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for the dev server."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Add your API routes here
|
||||
# @app.get("/api/example")
|
||||
# async def example():
|
||||
# return {"message": "Hello World"}
|
||||
|
||||
|
||||
# Final catch-all route for frontend files (keep at end of file)
|
||||
frontend.route(app, "/")
|
||||
|
||||
@@ -6,34 +6,30 @@
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Environment variables (with defaults):
|
||||
* VITE_PORT=5173 - Vite dev server port
|
||||
* VITE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||
*/
|
||||
|
||||
const backendUrl =
|
||||
process.env.VITE_BACKEND_URL || "http://localhost:{{BACKEND_PORT}}";
|
||||
const vitePort = parseInt(process.env.VITE_PORT || "{{VITE_PORT}}");
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
|
||||
export default function fastapiVue() {
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
config: () => ({
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: vitePort,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../{{MODULE_NAME}}/frontend-build",
|
||||
outDir: "../MODULE_NAME/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+56
-22
@@ -2,22 +2,22 @@
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
Usage:
|
||||
uv run scripts/devserver.py [host:port]
|
||||
uv run scripts/devserver.py [host:port] [--backend host:port]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||
Backend always listens on localhost:{{BACKEND_PORT}}.
|
||||
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
|
||||
|
||||
Environment:
|
||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from urllib.parse import urlparse
|
||||
@@ -27,10 +27,16 @@ import httpx
|
||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_VITE_PORT = {{VITE_PORT}}
|
||||
BACKEND_PORT = {{BACKEND_PORT}}
|
||||
DEFAULT_VITE_PORT = 5173
|
||||
DEFAULT_BACKEND_PORT = 5180
|
||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
EPILOG = """
|
||||
scripts/devserver.py # Default ports on localhost
|
||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
@@ -39,13 +45,15 @@ BUN_BUG = """\
|
||||
"""
|
||||
|
||||
|
||||
def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]:
|
||||
"""Parse an endpoint for Vite (no unix socket support).
|
||||
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_VITE_PORT, False
|
||||
return DEFAULT_HOST, default_port, False
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
@@ -64,12 +72,12 @@ def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||
return value, DEFAULT_VITE_PORT, False
|
||||
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_VITE_PORT
|
||||
port = parsed.port or default_port
|
||||
|
||||
return host, port, False
|
||||
|
||||
@@ -86,7 +94,7 @@ def resolve_frontend_tools(
|
||||
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
result = find_js_runtime()
|
||||
result = find_js_runtime() # noqa # type: ignore
|
||||
if result is None:
|
||||
if not os.environ.get("JS_RUNTIME"):
|
||||
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||
@@ -119,13 +127,13 @@ def resolve_frontend_tools(
|
||||
return install_cmd, dev_cmd, name
|
||||
|
||||
|
||||
async def wait_for_backend():
|
||||
async def wait_for_backend(backend_host: str, backend_port: int):
|
||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||
max_attempts = 50
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(f"http://localhost:{BACKEND_PORT}", timeout=1.0)
|
||||
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
|
||||
stderr.write("✓ Backend ready!\n")
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
@@ -155,7 +163,11 @@ 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
|
||||
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(
|
||||
@@ -167,7 +179,7 @@ async def run_devserver(
|
||||
f"http://{vite_host or 'localhost'}:{vite_port}"
|
||||
)
|
||||
# Tell Vite where the backend is (for proxying /api requests)
|
||||
os.environ["VITE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||
cwd = str(Path(__file__).parent.parent)
|
||||
frontend_cwd = str(FRONTEND_PATH)
|
||||
|
||||
@@ -187,11 +199,11 @@ async def run_devserver(
|
||||
# Start backend (concurrent with install)
|
||||
backend_cmd = [
|
||||
"uvicorn",
|
||||
"{{MODULE_NAME}}.app:app",
|
||||
"MODULE_NAME.app:app",
|
||||
"--host",
|
||||
"localhost",
|
||||
backend_host,
|
||||
"--port",
|
||||
str(BACKEND_PORT),
|
||||
str(backend_port),
|
||||
"--reload",
|
||||
]
|
||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||
@@ -200,7 +212,7 @@ async def run_devserver(
|
||||
# Wait for install to complete and backend to be ready
|
||||
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||
backend_ready_task = asyncio.create_task(
|
||||
wait_for_backend(), name="backend_ready"
|
||||
wait_for_backend(backend_host, backend_port), name="backend_ready"
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
@@ -257,11 +269,33 @@ async def run_devserver(
|
||||
|
||||
|
||||
def main():
|
||||
hostport = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
vite_host, vite_port, all_ifaces = parse_endpoint(hostport)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"frontend",
|
||||
nargs="?",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:5173)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
||||
)
|
||||
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
|
||||
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(vite_host, vite_port, all_ifaces))
|
||||
asyncio.run(
|
||||
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,7 +4,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
@@ -23,7 +23,7 @@ class CustomBuildHook(BuildHookInterface):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
|
||||
@@ -51,8 +51,10 @@ 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)
|
||||
build = {
|
||||
"deno": ("task", "build"),
|
||||
"deno": ("task", "build-only"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user