Compare commits

..
17 Commits
Author SHA1 Message Date
LeoVasanko c106ce1a44 Add missing files. 2026-02-02 16:27:34 +00:00
LeoVasanko b7b1920846 Make devserver verify that no servers are already running before starting them. 2026-02-02 16:26:19 +00:00
LeoVasanko 8ccfa1f658 Simplified devserver script, fixed termination on failed npm install. 2026-02-02 15:56:19 +00:00
LeoVasanko c23401de88 Avoid external venv or of fastapi-vue-setup itself leaking into target project. Cleaner and faster uv processing. 2026-02-02 13:46:34 +00:00
LeoVasanko c271f66615 Work around some Microsoft idiocy to attain Windows compatibility. 2026-02-02 13:28:36 +00:00
LeoVasanko 458d798022 Refactored scripts to remove any extra code from build-frontend.py and devserver.py (for better user editability). Individual helper functions can still be imported from scripts/fastapi-vue/ for any customization needs. Removed devmode support from CLI entry point simplifying it. Instead running via FastAPI CLI in dev mode (but it is still easy to modify to use a custom CLI entry). 2026-01-30 18:15:52 +00:00
LeoVasanko f5fcd626e8 Update templates for fastapi_vue 0.5.0 catch-all toggle. 2026-01-25 01:07:56 +00:00
LeoVasanko 1d9ddf7dc5 Update target project python version requirement if needed. 2026-01-24 21:31:07 +00:00
LeoVasanko b189ae66e7 Smarter .gitignore patching. 2026-01-24 21:16:05 +00:00
LeoVasanko 4156eb2db3 Better handling of upgrades, an option to avoid overwriting main and devserver scripts, writing .new.py instead. Error out on unix socket in devserver. 2026-01-21 21:13:23 +00:00
LeoVasanko 3f6559b2db Simplify devserver script and remove its duplicated parse_endpoint function. 2026-01-18 01:15:34 +00:00
LeoVasanko 71d4e053a7 Strictly correct indent and formatting on Vue demo patches. 2026-01-17 23:21:01 +00:00
LeoVasanko 2dec331afb Setup script cleanup. Use discovered app module and var names when creating __main__.py from template. README updates. 2026-01-17 23:04:07 +00:00
LeoVasanko 64f43df3e3 Completely rewritten CLI main, support unix sockets and wildcard bindings. 2026-01-17 22:48:13 +00:00
LeoVasanko 6ccba2be9f Adjust vite args (cleanup, don't clear screen). 2026-01-17 22:20:27 +00:00
LeoVasanko 2b735528ab Fix another Deno issue running Vue without typescript. 2026-01-17 22:19:28 +00:00
LeoVasanko e6170549f0 README 2026-01-17 20:21:31 +00:00
10 changed files with 1100 additions and 690 deletions
+25 -23
View File
@@ -1,6 +1,6 @@
# 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 (npm recommended due to bugs in deno and bun). Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (node is recommended due to bugs in deno and bun).
## Features ## Features
@@ -61,17 +61,18 @@ Options:
## Port Configuration ## Port Configuration
The tool uses three distinct ports: In development, you access the Vite dev server at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Ports and hosts of Vite and FastAPI are configurable by `devserver.py` arguments.
| Port | Purpose | Used by | 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`
| ---- | --------------------- | ------------------------------ |
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
| 5180 | FastAPI in dev mode | uvicorn via devserver.py |
| 5080 | Production server | `uv run my-app` |
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Configurable with `devserver.py` arguments. ## Main CLI
If your project didn't already have `__main__.py`, we create one that runs the FastAPI app with richer configuration than what the FastAPI CLI offers. Running your module starts it in production mode, and optionally host:port may be given as argument to specify where it listens.
If you are running behind a reverse proxy like [Caddy](https://caddyserver.com/) on localhost, your app will trust the proxy headers it sends. However, if you need to configure another proxy host or IP, set `FORWARDED_ALLOW_IPS` env variable before running the server.
The devserver script depends on this CLI entry for running the backend. You will have to modify the `devserver.py` script if your app has its own incompatible main module. Note that we set FastAPI debug mode and Uvicorn reload when configured via `FASTAPI_VUE_BACKEND_URL` env variable (set by `devserver.py`), while for normal production use these stay disabled. The same variable also controls static files serving (disabled in dev mode).
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument.
## Vite Plugin Configuration ## Vite Plugin Configuration
@@ -96,26 +97,27 @@ The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localho
## Project Structure ## Project Structure
After patching, your project will have:
``` ```
my-app/ my-app/
├── frontend/ # Vue application ├── frontend/ # Vue application
│ ├── src/ │ ├── src/
│ ├── vite.config.ts # Builds to ../my_app/frontend-build │ ├── vite-plugin-fastapi.js
│ ├── vite.config.js
│ └── package.json │ └── package.json
├── my_app/ # Python package ├── my_app/ # Python module (files included in sdist)
│ ├── __init__.py │ ├── __init__.py
│ ├── __main__.py # CLI entry point │ ├── __main__.py # CLI entrypoint
│ ├── app.py # FastAPI application │ ├── app.py # FastAPI application
│ └── frontend-build/ # Built frontend (gitignored) │ └── frontend-build/ # Built frontend (gitignored)
├── scripts/ ├── scripts/
│ ├── devserver.py # Development server │ ├── devserver.py # CLI dev server (only in source tree)
│ └── fastapi-vue/ # Build utilities │ └── fastapi-vue/
└── pyproject.toml # Project configuration │ ├── build-frontend.py
│ └── util.py
└── pyproject.toml
``` ```
The script finds your existing fastpi app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. The project directory tree looks roughly like this after project creation or patching. The script finds your existing app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
## Development Workflow ## Development Workflow
@@ -130,6 +132,6 @@ uv build
uv run my-app uv run my-app
``` ```
# Frontend serving ## Frontend serving
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend file. Refer to that package's documentation for further configuration. Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend files. Refer to that package's documentation for further configuration.
+598 -269
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"tomli-w>=1.0.0", "tomlkit>=0.12.0",
] ]
[project.urls] [project.urls]
+36 -22
View File
@@ -1,30 +1,44 @@
import sys # auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse
import asyncio
import os
import uvicorn from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
DEFAULT_PORT = 5080
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():
"""Run the FastAPI application using uvicorn.""" parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
parser.add_argument(
if len(sys.argv) > 1: "endpoint",
endpoint = sys.argv[1] nargs="?",
if ":" in endpoint: help=(
host, port = endpoint.rsplit(":", 1) f"Endpoint (default: localhost:{DEFAULT_PORT}). "
host = host or "localhost" "Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
port = int(port) ),
else:
host = "localhost"
port = int(endpoint)
else:
host = "localhost"
port = 5080
uvicorn.run(
"MODULE_NAME.app:app",
host=host,
port=port,
log_level="info",
) )
args = parser.parse_args()
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
try:
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
except KeyboardInterrupt:
pass
if __name__ == "__main__": if __name__ == "__main__":
+2 -4
View File
@@ -5,9 +5,7 @@ from fastapi import FastAPI
from fastapi_vue import Frontend from fastapi_vue import Frontend
# Vue Frontend static files # Vue Frontend static files
frontend = Frontend( frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
)
@asynccontextmanager @asynccontextmanager
@@ -29,5 +27,5 @@ async def health_check():
return {"status": "ok"} return {"status": "ok"}
# Final catch-all route for frontend files (keep at end of file) # Serve the Vue frontend (needs to be last if SPA catch-all is used)
frontend.route(app, "/") frontend.route(app, "/")
+46 -260
View File
@@ -1,278 +1,63 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
"""Run Vite development server for frontend and FastAPI backend with auto-reload. # auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload."""
Usage:
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.
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 argparse
import asyncio import asyncio
import contextlib import contextlib
import ipaddress
import os import os
import sys
from pathlib import Path from pathlib import Path
from sys import stderr
from urllib.parse import urlparse
import httpx # 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")))
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 from devutil import ( # type: ignore
ProcessGroup,
DEFAULT_HOST = "localhost" check_ports_free,
DEFAULT_VITE_PORT = 5173 logger,
DEFAULT_BACKEND_PORT = 5180 ready,
FRONTEND_PATH = Path(__file__).parent.parent / "frontend" setup_fastapi,
setup_vite,
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
┃ Consider using deno or npm instead for development.
"""
def parse_endpoint( async def run_devserver(frontend: str, backend: str) -> None:
value: str | None, default_port: int = DEFAULT_VITE_PORT reporoot = Path(__file__).parent.parent
) -> tuple[str | None, int, bool]: front = reporoot / "frontend"
"""Parse an endpoint for Vite or backend. if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front)
Returns (host, port, all_ifaces).
"""
if not value:
return DEFAULT_HOST, default_port, False
# Port only (numeric) -> localhost:port
if value.isdigit():
return DEFAULT_HOST, int(value), False
# Leading colon :port -> bind all interfaces
if value.startswith(":") and value != ":":
port_part = value[1:]
if not port_part.isdigit():
raise SystemExit(f"Invalid port in '{value}'")
return None, int(port_part), True
# Unbracketed IPv6 (cannot safely contain a port)
if value.count(":") > 1 and not value.startswith("["):
try:
ipaddress.IPv6Address(value)
except ValueError as e:
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
return value, default_port, False
# Use urllib.parse for everything else
parsed = urlparse(f"//{value}")
host = parsed.hostname or DEFAULT_HOST
port = parsed.port or default_port
return host, port, False
def resolve_frontend_tools(
vite_host: str | None, vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
raise SystemExit(1) raise SystemExit(1)
result = find_js_runtime() # noqa # type: ignore viteurl, npm_install, vite = setup_vite(frontend)
if result is None: backurl, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
tool, name = result # Tell the everyone where the frontend and backend are (vite proxy, etc)
os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl
os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
install_args = { async with ProcessGroup() as pg:
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"), npm_i = await pg.spawn(*npm_install, cwd=front)
"npm": ("install", "--silent"), await check_ports_free(viteurl, backurl)
"bun": ("install", "--silent"), await pg.spawn(
} *fastapi,
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
install_cmd = [tool, *install_args[name]]
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
if all_ifaces:
dev_cmd.append("--host")
elif vite_host:
dev_cmd.extend(["--host", vite_host])
if name == "bun":
stderr.write(BUN_BUG)
return install_cmd, dev_cmd, name
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://{backend_host}:{backend_port}", timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(
vite_host: str | None,
vite_port: int,
all_ifaces: bool,
backend_host: str,
backend_port: int,
) -> None:
"""Run the development server with install, backend, and frontend."""
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
vite_host, vite_port, all_ifaces
)
# Tell the backend where the Vite dev server is
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
f"http://{vite_host or 'localhost'}:{vite_port}"
)
# Tell Vite where the backend is (for proxying /api requests)
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
)
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload", "--reload",
] "--reload-dir=MODULE_NAME", # Don't reload on frontend changes
stderr.write(f">>> {' '.join(backend_cmd)}\n") "--forwarded-allow-ips=*",
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) cwd=reporoot,
# 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(backend_host, backend_port), name="backend_ready"
) )
done, pending = await asyncio.wait( # Wait for both install and backend to be ready
{install_task, backend_ready_task}, await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
return_when=asyncio.FIRST_COMPLETED,
)
for task in done: # Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
if task.get_name() == "install": await pg.spawn(*vite, cwd=front)
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers", description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG, epilog=HELP_EPILOG,
) )
parser.add_argument( parser.add_argument(
"frontend", "frontend",
@@ -286,16 +71,17 @@ def main():
help="FastAPI backend endpoint (default: localhost:5180)", help="FastAPI backend endpoint (default: localhost:5180)",
) )
args = parser.parse_args() 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): with contextlib.suppress(KeyboardInterrupt):
asyncio.run( asyncio.run(run_devserver(args.frontend, args.backend))
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
)
HELP_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
JS_RUNTIME environment variable can be used to select the JS runtime
"""
if __name__ == "__main__": if __name__ == "__main__":
+4 -23
View File
@@ -1,34 +1,15 @@
"""Hatch build hook for building Vue frontend during package build.""" """Hatch build hook for building Vue frontend during package build."""
import subprocess import sys
from pathlib import Path from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102 sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data): def initialize(self, version, build_data):
super().initialize(version, build_data) super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n") build("frontend")
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
+191
View File
@@ -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", "dev", "--"),
"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]).name, *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)
+197
View File
@@ -0,0 +1,197 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio
import subprocess
from collections.abc import Coroutine
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
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
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]).name
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."""
cleanup_task = asyncio.create_task(
self._cleanup(immediate=exc_type is not None)
)
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError:
# Shield was cancelled but cleanup_task continues - wait for it
await cleanup_task
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
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:
try:
p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout)
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
try:
await 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:
try:
p.kill()
except ProcessLookupError:
pass
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 with httpx.AsyncClient() as client:
for url in urls:
try:
res = await client.get(url, timeout=0.1)
logger.warning(
"Conflicting %s already running at %s",
res.headers.get("server", "server"),
url,
)
raise SystemExit(1)
except httpx.RequestError:
pass # Expected - port is free
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) -> 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_VITE_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}")
if port != 5173:
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 = DEFAULT_BACKEND_PORT
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build fastapi dev command.
Returns (url, 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 = [
"fastapi",
"dev",
"--entrypoint",
module,
"--host",
host,
"--port",
str(port),
]
return f"http://{host}:{port}", cmd
-88
View File
@@ -1,88 +0,0 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
options = ["deno", "npm", "bun"]
# 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:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
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"),
}
# 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-only"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name