diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py index afa429e..ade6ca7 100644 --- a/passkey/fastapi/__main__.py +++ b/passkey/fastapi/__main__.py @@ -244,9 +244,8 @@ def main(): run_kwargs["port"] = port if devmode: - if os.environ.get("PASSKEY_BUN_PARENT") != "1": - os.environ["PASSKEY_BUN_PARENT"] = "1" - frontend.run_dev() + os.environ["PASSKEY_DEVMODE"] = "1" + frontend.run_dev() if all_ifaces and not uds: if devmode: diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py index 14eb5d5..09c4c57 100644 --- a/passkey/fastapi/admin.py +++ b/passkey/fastapi/admin.py @@ -2,8 +2,8 @@ import logging from datetime import timezone from uuid import UUID, uuid4 -from fastapi import Body, FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse from ..authsession import reset_expires from ..globals import db @@ -33,7 +33,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException): """Handle AuthException with auth info for UI.""" return JSONResponse( status_code=exc.status_code, - content=authz.auth_error_content(exc), + content=await authz.auth_error_content(exc), ) @@ -45,7 +45,7 @@ async def general_exception_handler(_request, exc: Exception): @app.get("/") async def adminapp(request: Request, auth=AUTH_COOKIE): - return FileResponse(frontend.file("auth", "admin", "index.html")) + return Response(*await frontend.read("/auth/admin/index.html")) # -------------------- Organizations -------------------- diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index ef86e5e..82ab065 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -62,7 +62,7 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException): """Handle AuthException with auth info for UI.""" return JSONResponse( status_code=exc.status_code, - content=authz.auth_error_content(exc), + content=await authz.auth_error_content(exc), ) @@ -179,7 +179,7 @@ async def forward_authentication( if wants_html: # Browser request - return full-page HTML with metadata data_attrs = {"mode": e.mode, **e.metadata} - html = frontend.file("int", "forward", "index.html").read_bytes() + html = (await frontend.read("/int/forward/index.html"))[0] html = htmlutil.patch_html_data_attrs(html, **data_attrs) return Response( html, status_code=e.status_code, media_type="text/html; charset=UTF-8" @@ -188,7 +188,7 @@ async def forward_authentication( # API request - return JSON with iframe srcdoc HTML return JSONResponse( status_code=e.status_code, - content=authz.auth_error_content(e), + content=await authz.auth_error_content(e), ) diff --git a/passkey/fastapi/authz.py b/passkey/fastapi/authz.py index 2ab3a7e..60a32dc 100644 --- a/passkey/fastapi/authz.py +++ b/passkey/fastapi/authz.py @@ -32,13 +32,13 @@ class AuthException(HTTPException): self.metadata = metadata -def auth_error_content(exc: AuthException) -> dict: +async def auth_error_content(exc: AuthException) -> dict: """Generate JSON response content for an AuthException. Returns a dict with detail, mode, and iframe HTML for srcdoc embedding. """ data_attrs = {"mode": exc.mode, **exc.metadata} - iframe_html = frontend.file("auth", "restricted", "index.html").read_bytes() + iframe_html = (await frontend.read("/auth/restricted/index.html"))[0] iframe_html = htmlutil.patch_html_data_attrs(iframe_html, **data_attrs) return { "detail": exc.detail, diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index 0f2f97e..ad8a9af 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -3,7 +3,7 @@ import os from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request, Response -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from passkey.util import frontend, hostutil, passphrase @@ -64,7 +64,7 @@ app.mount( @app.get("/auth/restricted/") async def restricted_view(): """Serve the restricted/authentication UI for iframe embedding.""" - return FileResponse(frontend.file("auth", "restricted", "index.html")) + return Response(*await frontend.read("/auth/restricted/index.html")) # Navigable URLs are defined here. We support both / and /auth/ as the base path @@ -83,8 +83,8 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): cur_host = hostutil.normalize_host(request.headers.get("host")) cfg_normalized = hostutil.normalize_host(cfg_host) if cur_host and cfg_normalized and cur_host != cfg_normalized: - return FileResponse(frontend.file("int", "host", "index.html")) - return FileResponse(frontend.file("auth", "index.html")) + return Response(*await frontend.read("/int/host/index.html")) + return Response(*await frontend.read("/auth/index.html")) @app.get("/admin", include_in_schema=False) @@ -105,4 +105,4 @@ async def reset_link(reset: str): """Serve the reset app directly with an injected reset token.""" if not passphrase.is_well_formed(reset): raise HTTPException(status_code=404) - return FileResponse(frontend.file("int", "reset", "index.html")) + return Response(*await frontend.read("/int/reset/index.html")) diff --git a/passkey/fastapi/user.py b/passkey/fastapi/user.py index 4921e08..8038e07 100644 --- a/passkey/fastapi/user.py +++ b/passkey/fastapi/user.py @@ -29,7 +29,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException): """Handle AuthException with auth info for UI.""" return JSONResponse( status_code=exc.status_code, - content=authz.auth_error_content(exc), + content=await authz.auth_error_content(exc), ) diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py index 295cfb3..086f113 100644 --- a/passkey/fastapi/ws.py +++ b/passkey/fastapi/ws.py @@ -26,7 +26,7 @@ def websocket_error_handler(func): await ws.send_json( { "status": e.status_code, - **authz.auth_error_content(e), + **(await authz.auth_error_content(e)), } ) except (ValueError, InvalidAuthenticationResponse) as e: diff --git a/passkey/util/frontend.py b/passkey/util/frontend.py index aece0b9..02f9f00 100644 --- a/passkey/util/frontend.py +++ b/passkey/util/frontend.py @@ -1,4 +1,7 @@ +import asyncio import atexit +import mimetypes +import os import shutil import signal import subprocess @@ -7,7 +10,11 @@ from pathlib import Path from sys import stderr from threading import Thread -__all__ = ["path", "file", "run_dev"] +import httpx + +__all__ = ["path", "file", "read", "run_dev"] + +DEV_SERVER = "http://localhost:4403" NO_FRONTEND_TOOL = """\ ┃ ⚠️ deno, npm or bunx needed to run the frontend server. @@ -53,6 +60,45 @@ def file(*parts: str) -> Path: return path.joinpath(*parts) +def _is_dev_mode() -> bool: + """Check if we're running in dev mode (Vite frontend server).""" + return os.environ.get("PASSKEY_DEVMODE") == "1" + + +async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]: + """Read file content and return response tuple. + + In dev mode, fetches from the Vite dev server. + In production, reads from the static build directory. + + Args: + filepath: Path relative to frontend root, e.g. "/auth/index.html" + + Returns: + Tuple of (content, status_code, headers) suitable for + FastAPI Response(*args) or Sanic raw response. + """ + if _is_dev_mode(): + async with httpx.AsyncClient() as client: + resp = await client.get(f"{DEV_SERVER}{filepath}") + resp.raise_for_status() + mime = resp.headers.get("content-type", "application/octet-stream") + # Strip charset suffix if present + mime = mime.split(";")[0].strip() + return resp.content, resp.status_code, {"content-type": mime} + else: + # Production: read from static build + file_path = path / filepath.lstrip("/") + content = await _read_file_async(file_path) + mime, _ = mimetypes.guess_type(str(file_path)) + return content, 200, {"content-type": mime or "application/octet-stream"} + + +async def _read_file_async(file_path: Path) -> bytes: + """Read file asynchronously using asyncio.to_thread.""" + return await asyncio.to_thread(file_path.read_bytes) + + def run_dev(): """Spawn the frontend dev server (deno, npm, or bunx) as a background process.""" devpath = Path(__file__).parent.parent.parent / "frontend"