Update fastapi-vue-setup 1.6.1

This commit is contained in:
2026-09-15 05:20:57 +00:00
parent dc4bdf6efc
commit 4cd8dbfc73
11 changed files with 110 additions and 127 deletions
+5 -5
View File
@@ -8,11 +8,11 @@
* - Disables Vite's screen clearing on startup * - Disables Vite's screen clearing on startup
* *
* Options: * Options:
* paths - Array of paths to proxy (default: ["/api"]) * paths - Array of paths to proxy (default: ['/api'])
*/ */
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.PAGERITE_BACKEND_URL || "http://localhost:8210" const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
} }
return { return {
name: "vite-plugin-fastapi-pagerite", name: 'vite-plugin-fastapi-pagerite',
config: () => ({ config: () => ({
clearScreen: false, clearScreen: false,
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../pagerite/frontend-build", outDir: '../pagerite/frontend-build',
emptyOutDir: true, emptyOutDir: true,
}, },
}), }),
+1 -1
View File
@@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200' const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
// Proxy everything except Vite's own dev-time paths and the backend machinery // Proxy everything except Vite's own dev-time paths and the backend machinery
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are // to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
+3 -3
View File
@@ -5,12 +5,12 @@ import os
from pathlib import Path from pathlib import Path
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import env, server
from pagerite.config import Config from pagerite.config import Config
DEFAULT_PORT = 8100 DEFAULT_PORT = 8100
DEVMODE = os.getenv("PAGERITE_DEV") == "1" os.environ["FASTAPI_VUE"] = "PAGERITE"
def main() -> None: def main() -> None:
@@ -54,7 +54,7 @@ def main() -> None:
listen=args.listen, listen=args.listen,
default_port=DEFAULT_PORT, default_port=DEFAULT_PORT,
server_header=False, server_header=False,
reload=Path(__file__).parent if DEVMODE else False, reload=Path(__file__).parent if env.dev else False,
**run_args, **run_args,
) )
+2 -3
View File
@@ -36,11 +36,10 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import Response from fastapi.responses import Response
from fastapi_vue import Frontend from fastapi_vue import Frontend, env
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
from pagerite import api, files, pages, tracking from pagerite import api, files, pages, tracking
from pagerite.__main__ import DEVMODE
from pagerite.files import file_store from pagerite.files import file_store
from pagerite.state import analytics_store, config, kanta from pagerite.state import analytics_store, config, kanta
@@ -99,7 +98,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
# is not meant to be browsable by the public anyway. # is not meant to be browsable by the public anyway.
app = FastAPI( app = FastAPI(
title="Pagerite", title="Pagerite",
debug=DEVMODE, debug=env.dev,
lifespan=lifespan, lifespan=lifespan,
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
+2 -2
View File
@@ -22,11 +22,11 @@ from pathlib import Path
import blake3 import blake3
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.responses import Response from fastapi.responses import Response
from fastapi_vue import env
from kanta import Kanta from kanta import Kanta
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from pagerite import analytics, i18n, seed, translate, views from pagerite import analytics, i18n, seed, translate, views
from pagerite.__main__ import DEVMODE
from pagerite.chunks import store_chunks from pagerite.chunks import store_chunks
from pagerite.config import load from pagerite.config import load
from pagerite.data import ( from pagerite.data import (
@@ -230,7 +230,7 @@ def _html_response(
# Absolute social/canonical URLs use the site's public origin; on # Absolute social/canonical URLs use the site's public origin; on
# localhost (varying ports) fall back to the request's own base URL. # localhost (varying ports) fall back to the request's own base URL.
base_url = SITE_URL or str(request.base_url).rstrip("/") base_url = SITE_URL or str(request.base_url).rstrip("/")
if DEVMODE: if env.dev:
identity = _render_html(kind, path, base_url, lang, link_lang).encode() identity = _render_html(kind, path, base_url, lang, link_lang).encode()
body = _zstd.compress(identity) if zstd else identity body = _zstd.compress(identity) if zstd else identity
else: else:
+7 -6
View File
@@ -21,6 +21,7 @@ import json
import os import os
import re import re
from fastapi_vue import env
from html5tagger import HTML, Document, E, Template from html5tagger import HTML, Document, E, Template
from platformdirs import site_data_dir, user_data_path from platformdirs import site_data_dir, user_data_path
@@ -337,7 +338,7 @@ def _layout(
) -> Template: ) -> Template:
"""Page layout template with standard assets and ES-module scripts. """Page layout template with standard assets and ES-module scripts.
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev In dev (Vite dev-server URL set) assets are linked from the Vite dev
server and stylesheets use ``blocking="render"`` so the browser waits server and stylesheets use ``blocking="render"`` so the browser waits
for them before showing the page, avoiding a flash of unstyled content. for them before showing the page, avoiding a flash of unstyled content.
In production all page assets are inlined into the document: stylesheets In production all page assets are inlined into the document: stylesheets
@@ -394,7 +395,7 @@ def _layout(
# dev-server URLs as meta tags (Vite serves the modules and injects # dev-server URLs as meta tags (Vite serves the modules and injects
# their CSS for hot reloads); production inlines all page assets and # their CSS for hot reloads); production inlines all page assets and
# carries the on-demand URLs in one JSON script instead. # carries the on-demand URLs in one JSON script instead.
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = env.vite_url
editor_scripts, editor_css = _editor_assets() editor_scripts, editor_css = _editor_assets()
langselect_scripts, langselect_css = _langselect_assets() langselect_scripts, langselect_css = _langselect_assets()
config = { config = {
@@ -1226,7 +1227,7 @@ def _page_assets() -> tuple[list[str], list[str]]:
by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must
be linked separately. be linked separately.
""" """
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = env.vite_url
if vite_url: if vite_url:
return [f"{vite_url}/src/pagerite.js"], [] return [f"{vite_url}/src/pagerite.js"], []
if "page" not in _asset_cache: if "page" not in _asset_cache:
@@ -1244,7 +1245,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
The shared CSS is already linked on the page, so the pen only needs the The shared CSS is already linked on the page, so the pen only needs the
editor-specific stylesheet. editor-specific stylesheet.
""" """
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = env.vite_url
if vite_url: if vite_url:
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
if "editor" not in _asset_cache: if "editor" not in _asset_cache:
@@ -1256,7 +1257,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
def _analytics_assets() -> tuple[list[str], list[str]]: def _analytics_assets() -> tuple[list[str], list[str]]:
"""Script and stylesheet URLs for the analytics page entry.""" """Script and stylesheet URLs for the analytics page entry."""
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = env.vite_url
if vite_url: if vite_url:
return [f"{vite_url}/src/analytics-main.js"], [] return [f"{vite_url}/src/analytics-main.js"], []
if "analytics" not in _asset_cache: if "analytics" not in _asset_cache:
@@ -1270,7 +1271,7 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
def _langselect_assets() -> tuple[list[str], list[str]]: def _langselect_assets() -> tuple[list[str], list[str]]:
"""Script and stylesheet URLs for the on-demand public language selector.""" """Script and stylesheet URLs for the on-demand public language selector."""
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = env.vite_url
if vite_url: if vite_url:
return [f"{vite_url}/src/langselect-main.js"], [] return [f"{vite_url}/src/langselect-main.js"], []
if "langselect" not in _asset_cache: if "langselect" not in _asset_cache:
+1 -1
View File
@@ -17,7 +17,7 @@ readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"blake3>=1.0.9", "blake3>=1.0.9",
"fastapi-vue~=1.4.2", "fastapi-vue~=1.6.1",
"fastapi[standard]>=0.141.1", "fastapi[standard]>=0.141.1",
"html5tagger>=2.0.0", "html5tagger>=2.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
+10 -5
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for Vue app 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 os import os
import subprocess
import sys import sys
from contextlib import suppress
from pathlib import Path from pathlib import Path
import tracerite import tracerite
@@ -47,11 +48,11 @@ async def run_devserver(
os.environ["PAGERITE_DEV"] = "1" os.environ["PAGERITE_DEV"] = "1"
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front) npm_i = await pg.spawn(*npm_install, cwd=front)
await check_ports_free(viteurl, backurl) await pg.spawn(*pagerite, *(extra_args or []), vital=True)
await pg.spawn(*pagerite, *(extra_args or []))
await pg.wait(npm_i, ready(backurl, path=HEALTH)) await pg.wait(npm_i, ready(backurl, path=HEALTH))
await pg.spawn(*vite, cwd=front) await pg.spawn(*vite, cwd=front, vital=True)
def main() -> None: def main() -> None:
@@ -74,8 +75,12 @@ def main() -> None:
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
) )
args, extra_args = parser.parse_known_args() args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt): try:
asyncio.run(run_devserver(args.listen, args.backend, extra_args)) asyncio.run(run_devserver(args.listen, args.backend, extra_args))
except* KeyboardInterrupt:
pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError, RuntimeError:
raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """ HELP_EPILOG = """
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: INP001
"""Hatch build hook for building Vue frontend during package build.""" """Hatch build hook for building Vue frontend during package build."""
import sys import sys
+2 -3
View File
@@ -1,4 +1,3 @@
# ruff: noqa: INP001
"""Utilities used at build time and in devserver script. No dependencies.""" """Utilities used at build time and in devserver script. No dependencies."""
import logging import logging
@@ -33,7 +32,7 @@ def _check_node_version(node_path: str) -> None:
Raises RuntimeError if version is too old or cannot be determined. Raises RuntimeError if version is too old or cannot be determined.
""" """
try: try:
result = subprocess.run( # noqa: S603 result = subprocess.run(
[node_path, "--version"], [node_path, "--version"],
capture_output=True, capture_output=True,
text=True, text=True,
@@ -221,7 +220,7 @@ def build(folder: str = "frontend") -> None:
def run(cmd: list[str]) -> None: def run(cmd: list[str]) -> None:
display_cmd = [Path(cmd[0]).stem, *cmd[1:]] display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd)) logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603 subprocess.run(cmd, check=True, cwd=folder)
try: try:
run(install_cmd) run(install_cmd)
+77 -97
View File
@@ -1,111 +1,90 @@
# ruff: noqa: INP001
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio import asyncio
import subprocess
import sys import sys
from asyncio.subprocess import Process
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Self from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from buildutil import find_dev_tool, find_install_tool, logger
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine from collections.abc import Awaitable
class ProcessGroup: class ProcessGroup(asyncio.TaskGroup):
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" """TaskGroup with structured ownership of async subprocesses."""
def __init__(self) -> None: def __init__(self, *, terminate_timeout: float = 10) -> None:
"""Initialize empty process tracking.""" """Set the grace period before terminate() escalates to kill()."""
self._procs: list[asyncio.subprocess.Process] = [] super().__init__()
self._cmds: dict[int, str] = {} # pid -> command name self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
async def spawn( async def spawn(
self, self, *cmd: str, cwd: str | None = None, vital: bool = False
*cmd: str, ) -> Process:
cwd: str | None = None, """Spawn and own a subprocess. If a vital process exits, the group cancels."""
) -> 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( async def run() -> None:
self, name = Path(cmd[0]).stem
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]", logger.info(">>> %s", " ".join([name, *cmd[1:]]))
) -> None: try:
"""Wait for processes/coroutines to complete, raise SystemExit on failure.""" proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._cmds[proc] = cmd
started.set_result(proc)
except Exception as e: # noqa: BLE001
started.set_exception(e)
return
async def wait_proc(proc: asyncio.subprocess.Process) -> None: try:
returncode = await proc.wait() returncode = await proc.wait()
if returncode != 0: finally:
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) -> Self:
"""Enter the async context manager."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
"""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) -> None:
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): with suppress(ProcessLookupError):
p.terminate() proc.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: try:
await asyncio.shield( await asyncio.wait_for(proc.wait(), self._terminate_timeout)
asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
except TimeoutError: except TimeoutError:
for p in self._procs: with suppress(ProcessLookupError):
if p.returncode is None: proc.kill()
with suppress(ProcessLookupError): await proc.wait()
p.kill()
await p.wait() if vital:
logger.warning("Vital process %s exited", name)
raise CalledProcessError(returncode, cmd)
started = asyncio.get_running_loop().create_future()
self.create_task(run())
return await asyncio.shield(started)
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
"""Wait concurrently and return results in argument order."""
async def task(w: Process | Awaitable) -> Any:
if not isinstance(w, Process):
return await w
if retcode := await w.wait():
cmd = self._cmds[w]
logger.warning(
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
)
raise CalledProcessError(retcode, cmd)
return retcode
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(task(w)) for w in waitables]
return tuple(task.result() for task in tasks)
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109 async def http_get_server(url: str, timeout: float) -> str | None:
"""GET url with plain asyncio streams, return the response Server header. """GET url with plain asyncio streams, return the response Server header.
Returns an empty string when the server responds without a Server header, Returns an empty string when the server responds without a Server header,
@@ -128,31 +107,32 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
writer.close() writer.close()
except OSError, EOFError, ValueError, TimeoutError: except OSError, EOFError, ValueError, TimeoutError:
return None return None
for line in data.decode("latin-1").split("\r\n"): for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("server:"): if line.lower().startswith("server:"):
return line.split(":", 1)[1].strip() return line[7:].strip()
return "" return ""
async def check_ports_free(*urls: str) -> None: async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" """Verify URLs are not responding (ports are free).
async def check(url: str) -> None: Meant to run as a task inside a TaskGroup. Logs the conflict and raises
server = await http_get_server(url, timeout=0.1) RuntimeError (handled like a failed process) if any URL responds.
"""
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
for url, server in zip(urls, servers, strict=True):
if server is not None: if server is not None:
logger.warning( logger.error(
"Conflicting %s already running at %s", server or "server", url "Conflicting %s already running at %s", server or "server", url
) )
raise SystemExit(1) raise RuntimeError(url)
await asyncio.gather(*[check(url) for url in urls])
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint. """Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately. Use empty path to disable the check and make this return immediately.
Raises SystemExit(1) if server doesn't start in time. Logs, then raises RuntimeError if the server doesn't start in time.
""" """
if not path: if not path:
return return
@@ -162,8 +142,8 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
logger.info("✓ Backend ready!") logger.info("✓ Backend ready!")
return return
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.error("Backend at %s didn't start in time", url)
raise SystemExit(1) raise RuntimeError(url)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)