From ba6380e71ea51948e9dbf1b41b0c30cd74a7e6cf Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 21 Jan 2026 23:32:04 +0000 Subject: [PATCH] Add a script to run devserver. Migrate to build and JS utils provided by fastapi-vue. Frontend directory renamed to frontend-build. Update Sanic, deprecations. --- .gitignore | 2 +- cista/api.py | 4 +- cista/app.py | 12 +- cista/watching.py | 4 +- frontend/vite.config.ts | 7 +- pyproject.toml | 8 +- scripts/build-frontend.py | 37 ----- scripts/devserver.py | 210 ++++++++++++++++++++++++++ scripts/fastapi-vue/build-frontend.py | 34 +++++ scripts/fastapi-vue/util.py | 87 +++++++++++ 10 files changed, 349 insertions(+), 56 deletions(-) delete mode 100644 scripts/build-frontend.py create mode 100755 scripts/devserver.py create mode 100644 scripts/fastapi-vue/build-frontend.py create mode 100644 scripts/fastapi-vue/util.py diff --git a/.gitignore b/.gitignore index bc3d2d8..6f9b48e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ __pycache__/ *.egg-info/ /cista/_version.py -/cista/wwwroot/* +/cista/frontend-build/ /dist diff --git a/cista/api.py b/cista/api.py index 8f236cb..3a94085 100644 --- a/cista/api.py +++ b/cista/api.py @@ -15,12 +15,12 @@ fileserver = FileServer() @bp.before_server_start -async def start_fileserver(app, _): +async def start_fileserver(app): await fileserver.start() @bp.after_server_stop -async def stop_fileserver(app, _): +async def stop_fileserver(app): await fileserver.stop() diff --git a/cista/app.py b/cista/app.py index fc3691c..e52963e 100644 --- a/cista/app.py +++ b/cista/app.py @@ -36,19 +36,19 @@ setproctitle("cista-main") @app.before_server_start -async def main_start(app, loop): +async def main_start(app): config.load_config() setproctitle(f"cista {config.config.path.name}") workers = max(2, min(8, cpu_count())) app.ctx.threadexec = ThreadPoolExecutor( max_workers=workers, thread_name_prefix="cista-ioworker" ) - watching.start(app, loop) + watching.start(app) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) @app.before_server_stop -async def main_stop(app, loop): +async def main_stop(app): quit.set() watching.stop(app) app.ctx.threadexec.shutdown() @@ -75,7 +75,7 @@ async def use_session(req): @app.before_server_start -def http_fileserver(app, _): +def http_fileserver(app): bp = Blueprint("fileserver") bp.on_request(auth.verify) bp.static( @@ -93,9 +93,9 @@ www = {} def _load_wwwroot(www): wwwnew = {} - base = Path(__file__).with_name("wwwroot") + base = Path(__file__).with_name("frontend-build") paths = [PurePath()] - zstd = ZstdCompressor(level=10) + zstd = ZstdCompressor(level=18) while paths: path = paths.pop(0) current = base / path diff --git a/cista/watching.py b/cista/watching.py index 094b088..65824b1 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -440,14 +440,14 @@ def watcher_poll(loop): quit.wait(0.1 + 8 * dur) -def start(app, loop): +def start(app): global rootpath config.load_config() rootpath = config.config.path use_inotify = sys.platform == "linux" app.ctx.watcher = threading.Thread( target=watcher_inotify if use_inotify else watcher_poll, - args=[loop], + args=[app.loop], # Descriptive name for system monitoring name=f"cista-watcher {rootpath}", ) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c022e45..6ae9714 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,11 +7,8 @@ import vue from '@vitejs/plugin-vue' import svgLoader from 'vite-svg-loader' import Components from 'unplugin-vue-components/vite' -// Development mode: -// bun run dev # Run frontend that proxies to dev_backend -// cista -l :8000 --dev # Run backend const dev_backend = { - target: "http://localhost:8000", + target: process.env.CISTA_BACKEND_URL || "http://localhost:8989", changeOrigin: false, // Use frontend "host" to match "origin" from browser ws: true, } @@ -48,7 +45,7 @@ export default defineConfig({ } }, build: { - outDir: "../cista/wwwroot", + outDir: "../cista/frontend-build", emptyOutDir: true, } }) diff --git a/pyproject.toml b/pyproject.toml index 95c37ab..9d0a00a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "pillow-heif>=1.1.0", "pyjwt>=2.10.1", "pymupdf>=1.26.3", - "sanic>=25.3.0", + "sanic>=25.12.0", "setproctitle>=1.3.6", "stream-zip>=0.0.83", "tomli_w>=1.2.0", @@ -71,8 +71,8 @@ docs = [ source = "vcs" [tool.hatch.build] -artifacts = ["cista/wwwroot"] -targets.sdist.hooks.custom.path = "scripts/build-frontend.py" +artifacts = ["cista/frontend-build"] +targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py" targets.sdist.include = [ "/cista", ] @@ -82,6 +82,7 @@ hooks.vcs.template = """ __version__ = {version!r} """ only-packages = true +packages = ["cista"] [tool.pytest.ini_options] addopts = [ @@ -119,6 +120,7 @@ dev = [ "ruff>=0.8.0", "mypy>=1.13.0", "pre-commit>=4.0.0", + "httpx>=0.28.1", ] test = [ "pytest>=8.4.1", diff --git a/scripts/build-frontend.py b/scripts/build-frontend.py deleted file mode 100644 index a4b35b3..0000000 --- a/scripts/build-frontend.py +++ /dev/null @@ -1,37 +0,0 @@ -# noqa: INP001 -import os -import shutil -import subprocess -from sys import stderr - -from hatchling.builders.hooks.plugin.interface import BuildHookInterface - - -class CustomBuildHook(BuildHookInterface): - def initialize(self, version, build_data): - super().initialize(version, build_data) - stderr.write(">>> Building Cista frontend\n") - npm = None - bun = shutil.which("bun") - if bun is None: - npm = shutil.which("npm") - if npm is None: - raise RuntimeError( - "Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/" - ) - # npm --prefix doesn't work on Windows, so we chdir instead - os.chdir("frontend") - try: - if npm: - stderr.write("### npm install\n") - subprocess.run([npm, "install"], check=True) # noqa: S603 - stderr.write("\n### npm run build\n") - subprocess.run([npm, "run", "build"], check=True) # noqa: S603 - else: - assert bun - stderr.write("### bun install\n") - subprocess.run([bun, "install"], check=True) # noqa: S603 - stderr.write("\n### bun run build\n") - subprocess.run([bun, "run", "build"], check=True) # noqa: S603 - finally: - os.chdir("..") diff --git a/scripts/devserver.py b/scripts/devserver.py new file mode 100755 index 0000000..8efe787 --- /dev/null +++ b/scripts/devserver.py @@ -0,0 +1,210 @@ +#!/usr/bin/env -S uv run +"""Run Vite development server for frontend and Cista backend with auto-reload. + +Usage: + uv run scripts/devserver.py [-l ] + +Options: + -l LISTEN Listen address for backend (default: from config, or :8000) + +Environment: + JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun). +""" + +import asyncio +import contextlib +import os +import sys +from pathlib import Path +from sys import stderr + +import httpx + +from cista import config +from cista.serve import parse_listen + +exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 + +DEFAULT_VITE_PORT = 5173 +FRONTEND_PATH = Path(__file__).parent.parent / "frontend" + +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 resolve_frontend_tools(vite_port: int) -> 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) + + install_cmd, build_cmd = find_build_tool() # noqa # type: ignore + dev_cmd, name = find_dev_tool() # noqa # type: ignore + if dev_cmd is None: + if not os.environ.get("JS_RUNTIME"): + stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n") + raise SystemExit(1) + + dev_cmd = [*dev_cmd, "--clearScreen=false", f"--port={vite_port}"] + + if name == "bun": + stderr.write(BUN_BUG) + + return install_cmd, dev_cmd, name + + +async def wait_for_backend(host: str, port: int): + """Wait for the backend to be ready by polling the health endpoint.""" + max_attempts = 50 + url = f"http://{host}:{port}" + + async with httpx.AsyncClient() as client: + for attempt in range(max_attempts): + try: + await client.get(url, 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(backend_port: int, cista_args: list[str]) -> None: + """Run the development server with install, backend, and frontend.""" + vite_port = DEFAULT_VITE_PORT + install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port) + + # Tell the backend where the Vite dev server is (not used yet) + os.environ["CISTA_DEV_FRONTEND_URL"] = f"http://localhost:{vite_port}" + + backend_cmd = ["cista", "--dev", *cista_args] + + 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) + stderr.write(f">>> {' '.join(backend_cmd)}\n") + backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) + + # 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("localhost", backend_port), name="backend_ready" + ) + + done, pending = await asyncio.wait( + {install_task, backend_ready_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in done: + 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) + + 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(): + # Pass all arguments to cista, parse -l to determine backend port + cista_args = sys.argv[1:] + listen_arg = None + if "-l" in cista_args: + idx = cista_args.index("-l") + if idx + 1 < len(cista_args): + listen_arg = cista_args[idx + 1] + + # Load config to get the backend port + config.load_config() + listen = listen_arg or config.config.listen or ":8000" + _, opts = parse_listen(listen) + backend_port = opts.get("port", 8000) + + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(run_devserver(backend_port, cista_args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/fastapi-vue/build-frontend.py b/scripts/fastapi-vue/build-frontend.py new file mode 100644 index 0000000..7fccf65 --- /dev/null +++ b/scripts/fastapi-vue/build-frontend.py @@ -0,0 +1,34 @@ +"""Hatch build hook for building Vue frontend during package build.""" + +import subprocess +from pathlib import Path +from sys import stderr + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore + +exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102 + + +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): + """Build hook that compiles Vue frontend before packaging.""" + + def initialize(self, version, build_data): + super().initialize(version, build_data) + stderr.write(">>> Building the frontend\n") + + 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 diff --git a/scripts/fastapi-vue/util.py b/scripts/fastapi-vue/util.py new file mode 100644 index 0000000..236b2c5 --- /dev/null +++ b/scripts/fastapi-vue/util.py @@ -0,0 +1,87 @@ +"""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"), + } + # 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"), + } + + 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