Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e645a263 | ||
|
|
8b8a24a6b2 | ||
|
|
cb6017cedf | ||
|
|
fab4108e92 | ||
|
|
637f737d4c | ||
|
|
0b986717eb | ||
|
|
9469d57f90 | ||
|
|
1ce0fffe3e | ||
|
|
f483cf978c | ||
|
|
0e12e3a531 | ||
|
|
67bea9a2a7 | ||
|
|
a05c8236f6 | ||
|
|
fc3d6dc912 | ||
|
|
c63a375ab2 | ||
|
|
c98994fd1a | ||
|
|
6beaa88418 | ||
|
|
26a77dcf84 | ||
|
|
38c9ffe00b | ||
|
|
b87984af9a | ||
|
|
d3b6fccce0 | ||
|
|
d2ee7395c5 | ||
|
|
b94608ffe3 | ||
|
|
ef6bce8f3f | ||
|
|
b8bf46132a | ||
|
|
ca85c2fcfb | ||
|
|
440808361a | ||
|
|
05fd5c562e | ||
|
|
5f0e74713f | ||
|
|
2d117a9d5e | ||
|
|
4bf102579a | ||
|
|
fbed7873e5 | ||
|
|
008b3593db | ||
|
|
3820c6f3c1 | ||
|
|
c300a1a291 | ||
|
|
bdfaa22b0c | ||
|
|
47e5ec279c | ||
|
|
093b83737e | ||
|
|
a84041a923 | ||
|
|
9a9a00380f | ||
|
|
0e4f8f65f2 | ||
|
|
5391c81e8b | ||
|
|
c106ce1a44 | ||
|
|
b7b1920846 | ||
|
|
8ccfa1f658 |
@@ -1,137 +1,96 @@
|
|||||||
# fastapi-vue-setup
|
# 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 (node is recommended due to bugs in deno and bun).
|
Create or patch a FastAPI + Vue project with an integrated dev/build workflow.
|
||||||
|
|
||||||
## Features
|
- Development: one command runs Vite + FastAPI (reloads)
|
||||||
|
- Production: `uv build` bakes the built Vue assets into the Python package (no Node/JS runtime needed to *run* the installed package)
|
||||||
|
|
||||||
- **No JavaScript**: Your Python package can be installed and used without any JS runtime
|
## Quick start
|
||||||
- **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**: Caching, zstd compression and SPA support
|
|
||||||
|
|
||||||
## Installation
|
Install [UV](https://docs.astral.sh/uv/) and any JS runtime (node, deno, or bun).
|
||||||
|
|
||||||
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
|
This README uses `my-app` as the example project name:
|
||||||
|
|
||||||
```sh
|
- project directory: `my-app/`
|
||||||
uv tool install fastapi-vue-setup
|
- Python module: `my_app`
|
||||||
fastapi-vue-setup --help
|
- env prefix: `MY_APP`
|
||||||
```
|
- CLI command: `my-app`
|
||||||
|
|
||||||
Or run directly:
|
Create a new project in `./my-app`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uvx fastapi-vue-setup my-app
|
uvx fastapi-vue-setup my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
Once in your source tree, you will typically use `.` for the path. If there is an existing project, `fastapi-vue-setup` will do its best to find and patch a backend module and create or patch a Vue project in `frontend/`. The integration can be upgraded by running a new version of `fastapi-vue-setup` on it, preserving earlier default ports and user customizations.
|
||||||
|
|
||||||
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.
|
## In your project
|
||||||
|
|
||||||
### Create a new project
|
ℹ️ Everything below is meant to be run within your project source tree.
|
||||||
|
|
||||||
|
The setup creates a CLI entry for your package, so that it becomes a command to run, not a Python module nor `fastapi myapp...`. The CLI main can be customized, although --listen should be kept for devserver compatibility.
|
||||||
|
|
||||||
|
You can choose the JS runtime with environment `JS_RUNTIME` (e.g. `node`, `deno`, `bun`, or path to one). This is used by the build and the devserver scripts. By default any available runtime on the system is chosen.
|
||||||
|
|
||||||
|
### Development server (Vite + FastAPI)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
fastapi-vue-setup my-app
|
uv run scripts/devserver.py [args]
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
ℹ️ Arguments are forwarded to the main CLI, except that `--listen` controls where Vite listens, and `--backend` is passed to main CLI as `--listen`.
|
||||||
|
|
||||||
1. Run `uv init my-app`
|
### Production
|
||||||
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
|
Build the Python package (this compiles the Vue frontend) and run the production server:
|
||||||
|
|
||||||
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
|
```sh
|
||||||
fastapi-vue-setup .
|
uv build && uv run my-app [args]
|
||||||
```
|
```
|
||||||
|
|
||||||
### CLI Options
|
Once happy with it, publish the package
|
||||||
|
|
||||||
```
|
```sh
|
||||||
fastapi-vue-setup [project-dir] [options]
|
uv build && uv publish
|
||||||
|
|
||||||
Options:
|
|
||||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
|
||||||
--dry-run Preview changes without modifying files
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Port Configuration
|
Afterwards, you can easily run it anywhere, no JS runtimes required:
|
||||||
|
|
||||||
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.
|
```sh
|
||||||
|
uvx my-app [args]
|
||||||
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
|
|
||||||
|
|
||||||
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`.
|
ℹ️ Instead of `uvx` you may consider `uv tool install`, oldskool `pip install` or whatever best suits you.
|
||||||
|
|
||||||
## Project Structure
|
### Vite plugin
|
||||||
|
|
||||||
|
The generated Vite plugin lives in `frontend/vite-plugin-fastapi.js` and defaults to proxying `/api`.
|
||||||
|
|
||||||
|
It reads `MY_APP_BACKEND_URL` to know where to proxy; if unset it falls back to your configured default backend port.
|
||||||
|
|
||||||
|
## Project layout (typical)
|
||||||
|
|
||||||
```
|
```
|
||||||
my-app/
|
my-app/
|
||||||
├── frontend/ # Vue application
|
├── frontend/ # Vue app (Vite)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── vite-plugin-fastapi.js
|
│ ├── vite-plugin-fastapi.js
|
||||||
│ ├── vite.config.js
|
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── my_app/ # Python module (files included in sdist)
|
├── my_app/ # Python package
|
||||||
│ ├── __init__.py
|
│ ├── __main__.py # CLI entrypoint
|
||||||
│ ├── __main__.py # CLI entrypoint
|
│ ├── app.py # FastAPI app
|
||||||
│ ├── app.py # FastAPI application
|
│ └── frontend-build/ # built assets (included in distributions)
|
||||||
│ └── frontend-build/ # Built frontend (gitignored)
|
├── pyproject.toml
|
||||||
├── scripts/
|
└── scripts/
|
||||||
│ ├── devserver.py # CLI dev server (only in source tree)
|
├── devserver.py # Run Vite and FastAPI together in dev mode
|
||||||
│ └── fastapi-vue/
|
└── fastapi-vue/ # Dev utilities (only on the source tree)
|
||||||
│ ├── build-frontend.py
|
├── build-frontend.py
|
||||||
│ └── util.py
|
├── buildutil.py
|
||||||
└── pyproject.toml
|
└── devutil.py
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## The fastapi-vue runtime module
|
||||||
|
|
||||||
## Development Workflow
|
The backend runs the FastAPI app and serves the frontend build using the companion package in [fastapi-vue/README.md](fastapi-vue/README.md). Your project will depend on Fastapi and this lightweight module.
|
||||||
|
|
||||||
```bash
|
ℹ️ Development functionality is in `scripts/fastapi-vue/` directly in your source tree, and is not to be confused with this runtime module. Only the runtime is installed with your package.
|
||||||
# Start dev server (runs both Vite and FastAPI)
|
|
||||||
uv run scripts/devserver.py
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
uv build
|
|
||||||
|
|
||||||
# Run production server
|
|
||||||
uv run my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend serving
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# fastapi-vue
|
||||||
|
|
||||||
|
Runtime helpers for FastAPI + Vite/Vue projects.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This package provides:
|
||||||
|
|
||||||
|
- `fastapi_vue.Frontend`: serves built SPA assets (with SPA support, caching, and optional zstd)
|
||||||
|
- `fastapi_vue.server.run`: a small Uvicorn runner with convenient `listen` endpoint parsing
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Serve built frontend assets from `frontend-build/`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
|
frontend = Frontend(Path(__file__).with_name("frontend-build"), spa=True)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await frontend.load()
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Add API routes here...
|
||||||
|
|
||||||
|
# Final catch-all route for frontend files (keep at end of file)
|
||||||
|
frontend.route(app, "/")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
`Frontend` serves a directory with:
|
||||||
|
|
||||||
|
- RAM caching, with zstd compression when smaller than original
|
||||||
|
- Browser caching: ETag + Last-Modified, Immutable assets
|
||||||
|
- Favicon mapping (serve PNG or other images there instead)
|
||||||
|
- SPA routing (serve browsers index.html at all paths not otherwise handled)
|
||||||
|
|
||||||
|
Dev-mode behavior with `FastAPI(debug=True)`: requests error HTTP 409 with a message telling you to use the Vite dev server instead. Avoids accidentally using outdated `frontend-build` during development.
|
||||||
|
|
||||||
|
- `directory`: Path on local filesystem
|
||||||
|
- `index`: Index file name (default: `index.html`)
|
||||||
|
- `spa`: Serve index at any path (default: `False`)
|
||||||
|
- `catch_all`: Register a single catch-all handler instead of a route to each file; default for SPA
|
||||||
|
- `cached`: Path prefixes treated as immutable (default: `/assets/`)
|
||||||
|
- `favicon`: Optional path or glob (e.g. `/assets/logo*.png`)
|
||||||
|
- `zstdlevel`: Compression level (default: 18)
|
||||||
|
|
||||||
|
ℹ️ Even when your page has a meta tag giving favicon location, browsers still try loading `/favicon.ico` whenever looking at something else. We find it more convenient to simply serve the image where the browser expects it, with correct MIME type. This also allows having a default favicon for your application that can be easily overriden at the reverse proxy (Caddy, Nginx) to serve the company branding if needed in deployment.
|
||||||
|
|
||||||
|
## Server runner
|
||||||
|
|
||||||
|
When you need more flexibility than `fastapi` CLI can provide (e.g. CLI arguments to your own program), you may use this convenience to run FastAPI app with Uvicorn startup on given `listen` endpoints. Runs in the same process if possible but delegates to `uvicorn.run()` for auto-reloads and multiple workers. This would typically be called from your CLI main, which can set its own env variables to pass information to the FastAPI instances that run (Python imports only work in same-process mode).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_vue import server
|
||||||
|
|
||||||
|
server.run("my_app.app:app", listen=["localhost:8000"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- As a deployment option, environment `FORWARDED_ALLOW_IPS` controls `X-Forwarded` trusted IPs (default: `127.0.0.1,::1`).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .staticfiles import Frontend
|
||||||
|
|
||||||
|
__all__ = ["Frontend"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||||
|
"""Parse an endpoint string into uvicorn bind configurations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Endpoint string to parse
|
||||||
|
default_port: Port to use when not specified
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with uvicorn bind kwargs (host/port or uds).
|
||||||
|
Two entries may be returned for IPv4 and IPv6 (all interaces).
|
||||||
|
|
||||||
|
Supported forms:
|
||||||
|
- None or empty -> [{host: "localhost", port: default_port}]
|
||||||
|
- port (numeric) -> [{host: "localhost", port: port}]
|
||||||
|
- :port -> [{host: "0.0.0.0", port}, {host: "::", port}] (all interfaces)
|
||||||
|
- host:port -> [{host, port}]
|
||||||
|
- host -> [{host, port: default_port}]
|
||||||
|
- [ipv6]:port -> [{host: ipv6, port}]
|
||||||
|
- ipv6 (unbracketed) -> [{host: ipv6, port: default_port}]
|
||||||
|
- /path or unix:/path -> [{uds: path}]
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return [{"host": "localhost", "port": default_port}]
|
||||||
|
|
||||||
|
# Port only (numeric) -> localhost:port
|
||||||
|
if value.isdigit():
|
||||||
|
return [{"host": "localhost", "port": int(value)}]
|
||||||
|
|
||||||
|
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
||||||
|
if value.startswith(":") and value != ":":
|
||||||
|
port_part = value[1:]
|
||||||
|
if not port_part.isdigit():
|
||||||
|
raise SystemExit(f"Invalid port in '{value}'")
|
||||||
|
port = int(port_part)
|
||||||
|
return [{"host": "0.0.0.0", "port": port}, {"host": "::", "port": port}] # noqa: S104
|
||||||
|
|
||||||
|
# UNIX domain socket (unix:/path or just /path)
|
||||||
|
if value.startswith("/"):
|
||||||
|
return [{"uds": value}]
|
||||||
|
if value.startswith("unix:"):
|
||||||
|
uds_path = value[5:] or None
|
||||||
|
if uds_path is None:
|
||||||
|
raise SystemExit("unix: path must not be empty")
|
||||||
|
return [{"uds": uds_path}]
|
||||||
|
|
||||||
|
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
||||||
|
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 [{"host": value, "port": default_port}]
|
||||||
|
|
||||||
|
# Use urllib.parse for everything else (host[:port], [ipv6][:port])
|
||||||
|
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
||||||
|
host = parsed.hostname or "localhost"
|
||||||
|
port = parsed.port or default_port
|
||||||
|
|
||||||
|
# Validate IP literals (optional; hostname passes through)
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
|
||||||
|
return [{"host": host, "port": port}]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoints(
|
||||||
|
listen: str | list[str] | None = None, default_port: int = 8000
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Parse listen strings into a list of endpoint dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||||
|
default_port: Port to use when not specified in listen args.
|
||||||
|
"""
|
||||||
|
if listen is None:
|
||||||
|
listen = [f"localhost:{default_port}"]
|
||||||
|
elif isinstance(listen, str):
|
||||||
|
listen = [listen]
|
||||||
|
return [ep for s in listen for ep in parse_endpoint(s, default_port)]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
|
from .hostutil import parse_endpoints
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
app: str,
|
||||||
|
*,
|
||||||
|
listen: str | list[str] | None = None,
|
||||||
|
default_port: int = 8000,
|
||||||
|
reload: bool = False,
|
||||||
|
workers: int | None = None,
|
||||||
|
**uvicorn_config,
|
||||||
|
):
|
||||||
|
"""Run uvicorn server(s) for the given app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The ASGI application path (e.g., "myapp.main:app")
|
||||||
|
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||||
|
default_port: Port to use when not specified in listen args.
|
||||||
|
reload: Enable auto-reload (requires uvicorn.run, single endpoint only).
|
||||||
|
workers: Number of worker processes (requires uvicorn.run, single endpoint only).
|
||||||
|
**uvicorn_config: Additional uvicorn config options (overrides all other settings).
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoints(listen, default_port)
|
||||||
|
if not endpoints:
|
||||||
|
raise ValueError("No endpoints to serve; check listen configuration")
|
||||||
|
|
||||||
|
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
||||||
|
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||||
|
if proxy:
|
||||||
|
conf["proxy_headers"] = True
|
||||||
|
conf["forwarded_allow_ips"] = proxy
|
||||||
|
conf.update(uvicorn_config)
|
||||||
|
|
||||||
|
with suppress(KeyboardInterrupt):
|
||||||
|
if reload or workers:
|
||||||
|
serve_multiprocess(endpoints, **conf)
|
||||||
|
else:
|
||||||
|
asyncio.run(serve(endpoints, **conf))
|
||||||
|
|
||||||
|
|
||||||
|
async def serve(endpoints: list[dict], **kwargs) -> None:
|
||||||
|
"""Serve the given endpoints in current process/loop. Does not spawn extra processes."""
|
||||||
|
forbidden = {"reload", "workers"} & {k for k, v in kwargs.items() if v}
|
||||||
|
if forbidden:
|
||||||
|
logger.warning(
|
||||||
|
"Options %s have no effect in simple mode (multiple endpoints)",
|
||||||
|
", ".join(sorted(forbidden)),
|
||||||
|
)
|
||||||
|
await asyncio.gather(*(Server(Config(**kwargs, **ep)).serve() for ep in endpoints))
|
||||||
|
|
||||||
|
|
||||||
|
def serve_multiprocess(endpoints: list[dict], **kwargs) -> None:
|
||||||
|
"""Serve using uvicorn.run() for reload/workers support. Only first endpoint is used."""
|
||||||
|
if len(endpoints) > 1:
|
||||||
|
eps = [
|
||||||
|
ep["uds"] if "uds" in ep else f"{ep['host']}:{ep['port']}"
|
||||||
|
for ep in endpoints
|
||||||
|
]
|
||||||
|
logger.warning(
|
||||||
|
"Current mode supports only one endpoint. Listening: %s, skipped: %s",
|
||||||
|
eps[0],
|
||||||
|
" ".join(eps[1:]),
|
||||||
|
)
|
||||||
|
uvicorn.run(**kwargs, **endpoints[0])
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""FastAPI static file serving with zstd compression and SPA support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import time
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
|
from blake3 import blake3
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.routing import Route
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
|
||||||
|
|
||||||
|
__all__ = ["Frontend"]
|
||||||
|
|
||||||
|
|
||||||
|
class Assets:
|
||||||
|
"""Default cached value to /assets/"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(cached: str | list[str] | Assets) -> list[str]:
|
||||||
|
match cached:
|
||||||
|
case Assets():
|
||||||
|
return ["/assets/"]
|
||||||
|
case str():
|
||||||
|
return [cached]
|
||||||
|
case list():
|
||||||
|
return cached
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Invalid cached value: {cached!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class Frontend:
|
||||||
|
"""Static file server with automatic zstd compression and caching.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Automatic zstd compression for compressible files
|
||||||
|
- ETag-based caching of immutable assets
|
||||||
|
- SPA (Single Page Application) support
|
||||||
|
- /favicon.ico with correct MIME type (image/png etc)
|
||||||
|
- Dev mode: indexes files but returns error directing to Vite server
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the directory containing static files
|
||||||
|
index: Name of the index file (default: "index.html")
|
||||||
|
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
||||||
|
cached: Path prefixes that are immutable (default: "/assets/")
|
||||||
|
favicon: May use wildcards of full path. E.g. /assets/logo*.png matches logo.hash.png created by Vite
|
||||||
|
zstdlevel: Zstd compression level (default: 18)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
directory: Path | str,
|
||||||
|
*,
|
||||||
|
index: str = "index.html",
|
||||||
|
spa: bool = False,
|
||||||
|
catch_all: bool | None = None,
|
||||||
|
cached: str | list[str] | Assets = Assets(),
|
||||||
|
favicon: str | None = None,
|
||||||
|
zstdlevel: int = 18,
|
||||||
|
) -> None:
|
||||||
|
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
|
self.base: Path = Path(directory)
|
||||||
|
self.index = index
|
||||||
|
self.spa = spa
|
||||||
|
self._catch_all = spa if catch_all is None else catch_all
|
||||||
|
self.cached_paths = Assets.parse(cached)
|
||||||
|
self.zstdlevel = zstdlevel
|
||||||
|
self.favicon = favicon
|
||||||
|
self._app: FastAPI | None = None
|
||||||
|
self._mount_path: str = ""
|
||||||
|
self._ridx: int = 0
|
||||||
|
self._routes: list[Route] = []
|
||||||
|
|
||||||
|
def _index_only(self) -> set[str]:
|
||||||
|
"""Index file paths without loading content (for dev mode)."""
|
||||||
|
paths: set[str] = set()
|
||||||
|
if not self.base.exists():
|
||||||
|
return paths
|
||||||
|
queue = [PurePath()]
|
||||||
|
while queue:
|
||||||
|
current = self.base / queue.pop(0)
|
||||||
|
for p in current.iterdir():
|
||||||
|
rel = p.relative_to(self.base)
|
||||||
|
if p.is_dir():
|
||||||
|
queue.append(rel)
|
||||||
|
continue
|
||||||
|
name = "/" + rel.as_posix()
|
||||||
|
name = name.removesuffix(self.index)
|
||||||
|
paths.add(name)
|
||||||
|
if self.favicon:
|
||||||
|
p = PurePosixPath(self.favicon)
|
||||||
|
base = str(p.with_suffix(""))
|
||||||
|
ext = p.suffix
|
||||||
|
if any(path.startswith(base) and path.endswith(ext) for path in paths):
|
||||||
|
paths.add("/favicon.ico")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
"""Load static files from disk with compression."""
|
||||||
|
www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
|
if not self.base.exists():
|
||||||
|
raise ValueError(f"Frontend folder {self.base} not found (try uv build)")
|
||||||
|
paths = [PurePath()]
|
||||||
|
while paths:
|
||||||
|
current = self.base / paths.pop(0)
|
||||||
|
for p in current.iterdir():
|
||||||
|
rel = p.relative_to(self.base)
|
||||||
|
if p.is_dir():
|
||||||
|
paths.append(rel)
|
||||||
|
continue
|
||||||
|
# Read file
|
||||||
|
name = "/" + rel.as_posix()
|
||||||
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
|
name = name.removesuffix(self.index)
|
||||||
|
data = p.read_bytes()
|
||||||
|
etag = urlsafe_b64encode(blake3(data).digest(9)).decode()
|
||||||
|
if mime.startswith("text/"):
|
||||||
|
mime += "; charset=UTF-8"
|
||||||
|
mtime = p.stat().st_mtime
|
||||||
|
cached = any(name.startswith(prefix) for prefix in self.cached_paths)
|
||||||
|
headers = {
|
||||||
|
"etag": f'"{etag}"',
|
||||||
|
"last-modified": format_date_time(mtime),
|
||||||
|
"cache-control": (
|
||||||
|
"max-age=31536000, immutable" if cached else "no-cache"
|
||||||
|
),
|
||||||
|
"content-type": mime,
|
||||||
|
}
|
||||||
|
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
||||||
|
if len(zstd) >= len(data):
|
||||||
|
zstd = None
|
||||||
|
www[name] = data, zstd, headers
|
||||||
|
if self.favicon:
|
||||||
|
if m := fnmatch.filter(www, self.favicon):
|
||||||
|
data, zstd, headers = www[m[0]]
|
||||||
|
if "immutable" in headers.get("cache-control", ""):
|
||||||
|
headers = {**headers, "cache-control": "max-age=86400"}
|
||||||
|
www["/favicon.ico"] = data, zstd, headers
|
||||||
|
if not www:
|
||||||
|
msg = "Frontend files missing, check your installation.\n"
|
||||||
|
www["/"] = (
|
||||||
|
msg.encode(),
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"etag": "error",
|
||||||
|
"content-type": "text/plain",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return www
|
||||||
|
|
||||||
|
async def load(self, *, debug: bool | None = None, log: bool = True):
|
||||||
|
"""Load or reload static files from disk.
|
||||||
|
|
||||||
|
In debug mode, returns 409 instead of files (avoid accidental use of stale builds)
|
||||||
|
If debug is None, uses app.debug (app passed to frontend.route)
|
||||||
|
"""
|
||||||
|
if debug is None:
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
if debug:
|
||||||
|
# Dev mode: just index paths, no content loading
|
||||||
|
self._devmode_paths = await run_in_threadpool(self._index_only)
|
||||||
|
self._register_routes()
|
||||||
|
return
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
self.www = await run_in_threadpool(self._load)
|
||||||
|
self._register_routes()
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
if not log:
|
||||||
|
return
|
||||||
|
compfiles = [(len(d), len(z)) for d, z, _ in self.www.values() if z]
|
||||||
|
raw = sum(v[0] for v in compfiles)
|
||||||
|
comp = sum(v[1] for v in compfiles)
|
||||||
|
ratio = comp / raw * 100 if raw else 100.0
|
||||||
|
if log and self.www:
|
||||||
|
logger.info(
|
||||||
|
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
||||||
|
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
||||||
|
)
|
||||||
|
if self.favicon and "/favicon.ico" not in self.www:
|
||||||
|
logger.warning("Favicon not found: %s", self.favicon)
|
||||||
|
|
||||||
|
def route(self, app: FastAPI, mount_path="/"):
|
||||||
|
"""Register frontend routes with a FastAPI app.
|
||||||
|
|
||||||
|
In SPA/catch-all mode, this must only be called only after all other routes.
|
||||||
|
|
||||||
|
The calling position determines routing priority, although in regular mode the
|
||||||
|
routes are actually added only after load() is called.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: FastAPI application instance
|
||||||
|
mount_path: Path where the frontend should be mounted (default: "/")
|
||||||
|
"""
|
||||||
|
self._app = app
|
||||||
|
self._mount_path = mount_path.rstrip("/")
|
||||||
|
self._ridx = len(app.routes)
|
||||||
|
|
||||||
|
if self._catch_all:
|
||||||
|
# Register catch-all immediately (works without load)
|
||||||
|
path = self._mount_path + "{path:path}"
|
||||||
|
app.api_route(path, methods=["GET", "HEAD"], name="frontend")(self.handle)
|
||||||
|
|
||||||
|
def _register_routes(self):
|
||||||
|
"""Register individual routes for each loaded file (non-catch_all mode)."""
|
||||||
|
if self._app is None or self._catch_all:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove previously registered routes (for reload support)
|
||||||
|
for route in list(self._routes):
|
||||||
|
if route in self._app.routes:
|
||||||
|
self._app.routes.remove(route)
|
||||||
|
self._routes.clear()
|
||||||
|
|
||||||
|
# Get paths and select handler based on mode (checked once, not per request)
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
paths = self._devmode_paths if debug else self.www.keys()
|
||||||
|
handler = _devmode_respond if debug else self._respond
|
||||||
|
# Insert at the position where route() was called
|
||||||
|
self._app.routes[self._ridx : self._ridx] = self._routes = [
|
||||||
|
Route(
|
||||||
|
self._mount_path + p,
|
||||||
|
endpoint=handler if debug else partial(handler, name=p),
|
||||||
|
methods=["GET", "HEAD"],
|
||||||
|
name=f"frontend{p.replace('/', '_')}",
|
||||||
|
)
|
||||||
|
for p in paths
|
||||||
|
]
|
||||||
|
|
||||||
|
def _respond(self, request: Request, name: str):
|
||||||
|
"""Serve a static file with ETag and compression support."""
|
||||||
|
data, zstd, headers = self.www[name]
|
||||||
|
if request.headers.get("if-none-match") == headers["etag"]:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
||||||
|
return Response(
|
||||||
|
content=zstd, headers={**headers, "content-encoding": "zstd"}
|
||||||
|
)
|
||||||
|
return Response(content=data, headers=headers)
|
||||||
|
|
||||||
|
def handle(self, request: Request, path: str):
|
||||||
|
"""SPA catch-all handler with directory redirects and fallback to index."""
|
||||||
|
name = path.removesuffix(self.index)
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
files = self._devmode_paths if debug else self.www
|
||||||
|
|
||||||
|
if name not in files:
|
||||||
|
# Friendly redirect for directories missing trailing slash
|
||||||
|
if name and f"{name}/" in files:
|
||||||
|
return RedirectResponse(request.url.path + "/")
|
||||||
|
# SPA support: serve / for unknown paths if the browser wants HTML
|
||||||
|
if self.spa and "text/html" in request.headers.get("accept", ""):
|
||||||
|
name = "/"
|
||||||
|
# 404 for everything else
|
||||||
|
if name not in files:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
return (_devmode_respond if debug else self._respond)(request, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _devmode_respond(request: Request, name=""):
|
||||||
|
"""Return error response directing to Vite server."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"detail": "[devmode] Not serving frontend files here. Should you connect to Vite instead?"
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "fastapi-vue"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Serves Vue assets on a FastAPI app. Use fastapi-vue-setup tool to add Vue build to your package."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"zstandard>=0.23.0",
|
||||||
|
"blake3>=1.0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue"
|
||||||
|
Repository = "https://github.com/LeoVasanko/fastapi-vue"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling", "hatch-vcs"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "vcs"
|
||||||
|
raw-options.root = ".."
|
||||||
+576
-165
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -9,6 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ruff>=0.14.13",
|
||||||
"tomlkit>=0.12.0",
|
"tomlkit>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,4 +27,7 @@ source = "vcs"
|
|||||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["ruff"]
|
dev = ["ruff", "fastapi-vue"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
fastapi-vue = { path = "fastapi-vue", editable = true }
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
"""Build and release both fastapi-vue and fastapi-vue-setup packages."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DIST = ROOT / "dist"
|
||||||
|
FASTAPI_VUE = ROOT / "fastapi-vue"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Clear the dist directory
|
||||||
|
if DIST.exists():
|
||||||
|
shutil.rmtree(DIST)
|
||||||
|
DIST.mkdir()
|
||||||
|
|
||||||
|
# Build fastapi-vue (subdirectory) to root dist
|
||||||
|
subprocess.run(
|
||||||
|
["uv", "build", "--out-dir", str(DIST)],
|
||||||
|
cwd=FASTAPI_VUE,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build fastapi-vue-setup (root)
|
||||||
|
subprocess.run(
|
||||||
|
["uv", "build", "--out-dir", str(DIST)],
|
||||||
|
cwd=ROOT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,44 +1,29 @@
|
|||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue import server
|
||||||
from uvicorn import Config, Server
|
|
||||||
|
|
||||||
DEFAULT_PORT = 5080
|
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||||
|
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"
|
||||||
|
|
||||||
def run_server(endpoints: list[dict], *, proxy=""):
|
|
||||||
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
|
|
||||||
if proxy:
|
|
||||||
conf["proxy_headers"] = True
|
|
||||||
conf["forwarded_allow_ips"] = proxy
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"endpoint",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
help=(
|
action="append",
|
||||||
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
|
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||||
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||||
try:
|
server.run(
|
||||||
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
|
"APP_MODULE:APP_VAR",
|
||||||
except KeyboardInterrupt:
|
listen=args.listen,
|
||||||
pass
|
default_port=DEFAULT_PORT,
|
||||||
|
**dev,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
from MAIN_MODULE import DEVMODE
|
||||||
|
|
||||||
# Vue Frontend static files
|
# Vue Frontend static files
|
||||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
frontend = Frontend(Path(__file__).with_name("frontend-build"))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -15,7 +16,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan)
|
app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
# Add API routes here...
|
# Add API routes here...
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* FastAPI-Vue Vite Plugin
|
* FastAPI-Vue Vite Plugin
|
||||||
|
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||||
*
|
*
|
||||||
* Configures Vite for FastAPI backend integration:
|
* Configures Vite for FastAPI backend integration:
|
||||||
* - Proxies /api/* requests to the FastAPI backend
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
*
|
*
|
||||||
* Environment variables (with defaults):
|
* Options:
|
||||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
* paths - Array of paths to proxy (default: ["/api"])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEV_PORT"
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "fastapi-vite",
|
name: "vite-plugin-fastapi-MODULE_NAME",
|
||||||
config: () => ({
|
config: () => ({
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -1,56 +1,51 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload."""
|
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
from devutil import ( # type: ignore
|
from devutil import ( # type: ignore
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
ready,
|
ready,
|
||||||
setup_fastapi,
|
setup_cli,
|
||||||
setup_vite,
|
setup_vite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
||||||
|
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
||||||
|
|
||||||
async def run_devserver(frontend: str, backend: str) -> None:
|
|
||||||
|
async def run_devserver(
|
||||||
|
listen: str, backend: str, extra_args: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
logger.warning("Frontend source not found at %s", front)
|
logger.warning("Frontend source not found at %s", front)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend)
|
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||||
backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
|
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
|
||||||
|
|
||||||
# Tell the everyone where the frontend and backend are (vite proxy, etc)
|
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
||||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url
|
os.environ["ENVPREFIX_VITE_URL"] = viteurl
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
|
||||||
|
os.environ["ENVPREFIX_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
install_proc = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await asyncio.sleep(0.2) # reduce message overlap
|
await check_ports_free(viteurl, backurl)
|
||||||
await pg.spawn(
|
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
||||||
*fastapi,
|
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||||
"--reload",
|
|
||||||
"--reload-dir=MODULE_NAME", # Don't reload on frontend changes
|
|
||||||
"--forwarded-allow-ips=*",
|
|
||||||
cwd=reporoot,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for both install and backend to be ready
|
|
||||||
async with asyncio.TaskGroup() as tg:
|
|
||||||
tg.create_task(pg.wait(install_proc))
|
|
||||||
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
|
|
||||||
|
|
||||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,27 +56,26 @@ def main():
|
|||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"frontend",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help="Vite frontend endpoint (default: localhost:5173)",
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py # Default ports on localhost
|
Other options are forwarded to PROJECT_CLI [args]
|
||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
|
||||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
|
||||||
|
|
||||||
JS_RUNTIME environment variable can be used to select the JS runtime
|
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||||
|
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class _PrefixFormatter(logging.Formatter):
|
||||||
|
"""Formatter that adds prefix based on log level."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return f"⚠️ {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(_PrefixFormatter())
|
||||||
|
logger = logging.getLogger("fastapi-vue")
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
"""Check if Node.js version is >= 20.
|
||||||
|
|
||||||
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
version_str = result.stdout.strip()
|
||||||
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
|
match = re.match(r"v(\d+)", version_str)
|
||||||
|
if match:
|
||||||
|
major_version = int(match.group(1))
|
||||||
|
if major_version >= 20:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Could not determine Node.js version")
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Raises JSRuntimeError if no suitable runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["npm", "deno", "bun"]
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
# Check for JS_RUNTIME environment variable
|
||||||
|
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
for option in options:
|
||||||
|
if option == runtime_name or runtime_name.startswith(option):
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
)
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
)
|
||||||
|
_check_node_version(node_path) # Raises on failure
|
||||||
|
return tool, option
|
||||||
|
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||||
|
|
||||||
|
# Auto-detect
|
||||||
|
for option in options:
|
||||||
|
if tool := shutil.which(option):
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue # Try next runtime
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
# No runtime found - provide helpful error
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||||
|
|
||||||
|
|
||||||
|
def find_build_tool():
|
||||||
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install = {
|
||||||
|
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install",),
|
||||||
|
"bun": ("--bun", "install"),
|
||||||
|
}
|
||||||
|
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||||
|
build = {
|
||||||
|
"deno": ("run", "-A", "npm:vite", "build"),
|
||||||
|
"npm": ("run", "build"),
|
||||||
|
"bun": ("--bun", "run", "build"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns dev_cmd (without vite-specific args).
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "-A", "npm:vite"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
logger.warning(
|
||||||
|
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [tool, *dev_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_install_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct install command.
|
||||||
|
|
||||||
|
Returns install_cmd.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install_args = {
|
||||||
|
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install", "--silent"),
|
||||||
|
"bun": ("install", "--silent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def build(folder: str = "frontend") -> None:
|
||||||
|
"""Build the frontend in the specified folder.
|
||||||
|
|
||||||
|
Raises SystemExit(1) on failure.
|
||||||
|
"""
|
||||||
|
logger.info(">>> Building %s", folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_cmd, build_cmd = find_build_tool()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(e)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd)
|
||||||
|
logger.info("")
|
||||||
|
run(build_cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessGroup:
|
||||||
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
|
self._cmds: dict[int, str] = {} # pid -> command name
|
||||||
|
|
||||||
|
async def spawn(
|
||||||
|
self, *cmd: str, cwd: str | None = None
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a subprocess and track it."""
|
||||||
|
cmd_name = Path(cmd[0]).stem
|
||||||
|
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||||
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._procs.append(proc)
|
||||||
|
self._cmds[proc.pid] = cmd_name
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def wait(
|
||||||
|
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||||
|
) -> None:
|
||||||
|
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||||
|
|
||||||
|
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||||
|
returncode = await proc.wait()
|
||||||
|
if returncode != 0:
|
||||||
|
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||||
|
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||||
|
for w in waitables
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, *_):
|
||||||
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
|
await self._cleanup(immediate=exc_type is not None)
|
||||||
|
|
||||||
|
async def _cleanup(self, immediate: bool = False):
|
||||||
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not immediate:
|
||||||
|
# Wait for any one process to exit
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await asyncio.wait(
|
||||||
|
[asyncio.create_task(p.wait()) for p in running],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Terminate remaining processes
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
p.terminate()
|
||||||
|
|
||||||
|
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||||
|
still_running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if still_running:
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
asyncio.wait_for(
|
||||||
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
p.kill()
|
||||||
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ports_free(*urls: str) -> None:
|
||||||
|
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||||
|
|
||||||
|
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||||
|
with suppress(httpx.RequestError):
|
||||||
|
res = await client.get(url, timeout=0.1)
|
||||||
|
server = res.headers.get("server", "server")
|
||||||
|
logger.warning("Conflicting %s already running at %s", server, url)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await asyncio.gather(*[check(client, url) for url in urls])
|
||||||
|
|
||||||
|
|
||||||
|
async def ready(url: str, path: str = "") -> None:
|
||||||
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
|
"""
|
||||||
|
max_attempts = 50
|
||||||
|
full_url = f"{url}{path}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(full_url, timeout=1.0)
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
logger.warning("Backend didn't start in time")
|
||||||
|
raise SystemExit(1)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vite(
|
||||||
|
endpoint: str, default_port: int = 5173
|
||||||
|
) -> tuple[str, list[str], list[str]]:
|
||||||
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
|
Returns (url, install_cmd, dev_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
|
||||||
|
install_cmd = find_install_tool()
|
||||||
|
dev_cmd = find_dev_tool()
|
||||||
|
if host != "localhost":
|
||||||
|
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||||
|
dev_cmd.append(f"--port={port}")
|
||||||
|
|
||||||
|
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_fastapi(
|
||||||
|
endpoint: str, module: str, default_port: int = 8000
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
|
Returns (url, uvicorn_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
module,
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
"--reload",
|
||||||
|
f"--reload-dir={reload_dir}",
|
||||||
|
"--forwarded-allow-ips=*",
|
||||||
|
]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_cli(
|
||||||
|
cli: str, endpoint: str, default_port: int = 8000
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build CLI command.
|
||||||
|
|
||||||
|
Returns (url, cli_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
|
cmd = [cli, f"--listen={host}:{port}"]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
Reference in New Issue
Block a user