Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632230d05c | ||
|
|
a37198bb4b | ||
|
|
fb71ea1220 | ||
|
|
25283eba9b | ||
|
|
6a217978d6 |
+1
-1
@@ -5,7 +5,7 @@ dist/
|
||||
*.lock
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
paskia.jsonl
|
||||
*.paskiadb
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
|
||||
@@ -51,7 +51,7 @@ uv tool install paskia
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is passed by CLI arguments, of which there are just a few.
|
||||
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
|
||||
|
||||
```text
|
||||
paskia [options]
|
||||
@@ -61,9 +61,12 @@ paskia [options]
|
||||
|--------|-------------|---------|
|
||||
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
||||
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
||||
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
|
||||
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
|
||||
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
|
||||
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
|
||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
|
||||
## Tutorial: From Local Testing to Production
|
||||
|
||||
@@ -84,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
|
||||
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||
|
||||
```fish
|
||||
paskia --rp-id example.com --rp-name "Example Corp"
|
||||
paskia --rp-id example.com --rp-name "Example Corp" --save
|
||||
```
|
||||
|
||||
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration.
|
||||
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`.
|
||||
|
||||
### Step 3: Set Up Caddy
|
||||
|
||||
@@ -187,7 +190,7 @@ Description=Paskia Authentication Server
|
||||
Type=simple
|
||||
User=paskia
|
||||
WorkingDirectory=/srv/paskia
|
||||
ExecStart=uvx paskia --rp-id example.com --rp-name "Example Corp"
|
||||
ExecStart=uvx paskia --rp-id=example.com
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* FastAPI-Vue Vite Plugin
|
||||
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||
*
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Environment variables (with defaults):
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||
* Options:
|
||||
* 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"] } = {}) {
|
||||
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
name: "vite-plugin-fastapi-paskia",
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
|
||||
@@ -47,6 +47,7 @@ from paskia.db.operations import (
|
||||
delete_session,
|
||||
delete_sessions_for_user,
|
||||
delete_user,
|
||||
get_config,
|
||||
get_organization_users,
|
||||
get_reset_token,
|
||||
get_user_credential_ids,
|
||||
@@ -55,6 +56,7 @@ from paskia.db.operations import (
|
||||
login,
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
set_config,
|
||||
set_session_host,
|
||||
update_credential_sign_count,
|
||||
update_org_name,
|
||||
@@ -110,6 +112,7 @@ __all__ = [
|
||||
"build_session",
|
||||
"build_user",
|
||||
# Read ops
|
||||
"get_config",
|
||||
"get_organization_users",
|
||||
"get_reset_token",
|
||||
"get_user_credential_ids",
|
||||
@@ -138,6 +141,7 @@ __all__ = [
|
||||
"login",
|
||||
"remove_permission_from_org",
|
||||
"remove_permission_from_role",
|
||||
"set_config",
|
||||
"set_session_host",
|
||||
"update_credential_sign_count",
|
||||
"update_org_name",
|
||||
|
||||
+7
-2
@@ -130,10 +130,13 @@ class JsonlStore:
|
||||
self._transaction_snapshot: dict[str, Any] | None = None
|
||||
self._current_version: int = DBVER # Schema version for new databases
|
||||
|
||||
async def load(self, db_path: str | None = None) -> None:
|
||||
async def load(
|
||||
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
||||
) -> None:
|
||||
"""Load data from JSONL change log."""
|
||||
if db_path is not None:
|
||||
self.db_path = Path(db_path)
|
||||
self._rp_id = rp_id
|
||||
if not self.db_path.exists():
|
||||
return
|
||||
|
||||
@@ -169,7 +172,9 @@ class JsonlStore:
|
||||
self._queue_change(action, new_version, current)
|
||||
|
||||
# Apply schema migrations one at a time
|
||||
await apply_all_migrations(data_dict, self._current_version, persist_migration)
|
||||
await apply_all_migrations(
|
||||
data_dict, self._current_version, persist_migration, rp_id=rp_id
|
||||
)
|
||||
|
||||
# Decode to msgspec struct
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
|
||||
+10
-2
@@ -8,12 +8,18 @@ Each migration should be idempotent and only run when needed.
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
|
||||
def migrate_v1(d: dict) -> None:
|
||||
def migrate_v1(d: dict, **kwargs) -> None:
|
||||
"""Remove Org.created_at fields."""
|
||||
for org_data in d["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
|
||||
"""Add config field if missing."""
|
||||
if "config" not in d:
|
||||
d["config"] = {"rp_id": rp_id}
|
||||
|
||||
|
||||
migrations = sorted(
|
||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||
@@ -26,8 +32,10 @@ async def apply_all_migrations(
|
||||
data_dict: dict,
|
||||
current_version: int,
|
||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||
*,
|
||||
rp_id: str = "localhost",
|
||||
) -> None:
|
||||
while current_version < DBVER:
|
||||
migrations[current_version](data_dict)
|
||||
migrations[current_version](data_dict, rp_id=rp_id)
|
||||
current_version += 1
|
||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||
|
||||
+21
-6
@@ -17,11 +17,11 @@ import uuid7
|
||||
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.jsonl import (
|
||||
DB_PATH_DEFAULT,
|
||||
JsonlStore,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
@@ -42,16 +42,15 @@ _db._store = _store
|
||||
_initialized = False
|
||||
|
||||
|
||||
async def init(*args, **kwargs):
|
||||
async def init(rp_id: str = "localhost", *args, **kwargs):
|
||||
"""Load database from JSONL file."""
|
||||
global _db, _initialized
|
||||
if _initialized:
|
||||
_logger.debug("Database already initialized, skipping reload")
|
||||
return
|
||||
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
||||
if db_path.startswith("json:"):
|
||||
db_path = db_path[5:]
|
||||
await _store.load(db_path)
|
||||
default_path = f"{rp_id}.paskiadb"
|
||||
db_path = os.environ.get("PASKIA_DB", default_path)
|
||||
await _store.load(db_path, rp_id=rp_id)
|
||||
_db = _store.db
|
||||
_initialized = True
|
||||
|
||||
@@ -823,3 +822,19 @@ def bootstrap(
|
||||
_db.reset_tokens[reset_token.key] = reset_token
|
||||
|
||||
return reset_passphrase
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Config operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the stored configuration."""
|
||||
return _db.config
|
||||
|
||||
|
||||
async def set_config(config: Config) -> None:
|
||||
"""Update the stored configuration."""
|
||||
async with _db.transaction("update_config"):
|
||||
_db.config = config
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
import uuid7
|
||||
from msgspec import field
|
||||
|
||||
from paskia import db
|
||||
from paskia.util.hostutil import normalize_host
|
||||
@@ -397,6 +398,16 @@ class SessionContext(msgspec.Struct):
|
||||
permissions: list[Permission] = []
|
||||
|
||||
|
||||
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
|
||||
"""Stored configuration for the instance."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
origins: list[str] | None = None
|
||||
auth_host: str | None = None
|
||||
listen: str | None = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Database storage structure
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -412,6 +423,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[bytes, ResetToken] = {}
|
||||
config: Config = field(default_factory=lambda: Config(rp_id="localhost"))
|
||||
|
||||
def __post_init__(self):
|
||||
# Store reference for persistence (not serialized)
|
||||
|
||||
@@ -6,17 +6,22 @@ import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from uvicorn import Config, Server
|
||||
from uvicorn import Config as UvicornConfig
|
||||
from uvicorn import Server
|
||||
from uvicorn import run as uvicorn_run
|
||||
|
||||
from paskia import globals as _globals
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.db import get_config, set_config
|
||||
from paskia.db import init as db_init
|
||||
from paskia.db.background import flush
|
||||
from paskia.db.structs import Config
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL"))
|
||||
|
||||
EPILOG = """\
|
||||
Example:
|
||||
@@ -61,6 +66,11 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
"--auth-host",
|
||||
help=("Dedicated authentication site (optionally with scheme/port)"),
|
||||
)
|
||||
p.add_argument(
|
||||
"--save",
|
||||
action="store_true",
|
||||
help="Save the CLI options to database for future runs.",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -87,6 +97,28 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle clearing options
|
||||
if getattr(args, "auth_host", None) == "":
|
||||
args.auth_host = None
|
||||
if getattr(args, "rp_name", None) == "":
|
||||
args.rp_name = None
|
||||
if getattr(args, "listen", None) == "":
|
||||
args.listen = None
|
||||
|
||||
# Init db and load stored config
|
||||
asyncio.run(db_init(rp_id=args.rp_id))
|
||||
stored_config = get_config()
|
||||
|
||||
# Apply defaults from stored config
|
||||
if args.rp_name is None and stored_config.rp_name is not None:
|
||||
args.rp_name = stored_config.rp_name
|
||||
if args.origins is None and stored_config.origins is not None:
|
||||
args.origins = stored_config.origins
|
||||
if args.auth_host is None and stored_config.auth_host is not None:
|
||||
args.auth_host = stored_config.auth_host
|
||||
if args.listen is None and stored_config.listen is not None:
|
||||
args.listen = stored_config.listen
|
||||
|
||||
# Parse endpoint using fastapi_vue.hostutil
|
||||
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
|
||||
|
||||
@@ -167,14 +199,22 @@ def main():
|
||||
|
||||
startupbox.print_startup_config(config)
|
||||
|
||||
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
||||
if args.save:
|
||||
new_config = Config(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origins=args.origins,
|
||||
auth_host=args.auth_host,
|
||||
listen=args.listen,
|
||||
)
|
||||
asyncio.run(set_config(new_config))
|
||||
|
||||
run_kwargs: dict = {
|
||||
"log_level": "warning", # Suppress startup messages; we use custom logging
|
||||
"access_log": False, # We use custom AccessLogMiddleware instead
|
||||
}
|
||||
|
||||
if devmode:
|
||||
if DEVMODE:
|
||||
# Security: dev mode must run on localhost:4402 to prevent
|
||||
# accidental public exposure of the Vite dev server
|
||||
if host != "localhost" or port != 4402:
|
||||
@@ -197,16 +237,16 @@ def main():
|
||||
for ep in endpoints:
|
||||
tg.create_task(
|
||||
Server(
|
||||
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
|
||||
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep)
|
||||
).serve()
|
||||
)
|
||||
elif devmode:
|
||||
elif DEVMODE:
|
||||
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
|
||||
ep = endpoints[0]
|
||||
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
|
||||
else:
|
||||
server = Server(
|
||||
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
||||
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
||||
)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from paskia import globals
|
||||
from paskia.db import start_background, stop_background
|
||||
from paskia.db.logging import configure_db_logging
|
||||
from paskia.fastapi import admin, api, auth_host, ws
|
||||
from paskia.fastapi.__main__ import DEVMODE
|
||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
@@ -59,7 +60,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
|
||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||
if frontend.devmode:
|
||||
if app.debug:
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||
await frontend.load()
|
||||
@@ -74,6 +75,7 @@ app = FastAPI(
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
debug=DEVMODE,
|
||||
)
|
||||
|
||||
# Custom access logging (uvicorn's access_log is disabled)
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ async def init(
|
||||
|
||||
Database configuration:
|
||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
||||
Default: paskia.jsonl
|
||||
Default: {rp_id}.paskiadb
|
||||
"""
|
||||
|
||||
# Initialize passkey instance with provided parameters
|
||||
@@ -53,7 +53,7 @@ async def init(
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await db.init()
|
||||
await db.init(rp_id=rp_id)
|
||||
|
||||
# Initialize remote auth manager
|
||||
await remoteauth.init()
|
||||
|
||||
@@ -8,6 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
@@ -62,6 +63,7 @@ class Passkey:
|
||||
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
"""
|
||||
self.rp_id = rp_id
|
||||
self._validate_rp_id(rp_id)
|
||||
self.rp_name = rp_name or rp_id
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
@@ -75,6 +77,21 @@ class Passkey:
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
]
|
||||
|
||||
def _validate_rp_id(self, rp_id: str) -> None:
|
||||
"""Validate that rp_id is a valid domain name."""
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id cannot be empty")
|
||||
# Allow localhost, or domain-like strings
|
||||
if rp_id == "localhost":
|
||||
return
|
||||
# Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc.
|
||||
# Simplified: alphanumeric, dots, hyphens
|
||||
if not re.match(
|
||||
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
|
||||
rp_id,
|
||||
):
|
||||
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
|
||||
|
||||
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||
"""Validate an origin URL against the rp_id."""
|
||||
hostname = urlparse(origin).hostname
|
||||
|
||||
+36
-92
@@ -13,9 +13,9 @@ All other options are forwarded to `paskia`.
|
||||
Backend always listens on localhost:4402.
|
||||
|
||||
Environment:
|
||||
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||
FASTAPI_VUE_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
|
||||
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
|
||||
PASKIA_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||
PASKIA_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
|
||||
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
|
||||
|
||||
Options:
|
||||
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
|
||||
@@ -34,12 +34,16 @@ from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from buildutil import find_dev_tool, find_install_tool, logger # noqa: E402
|
||||
from devutil import ProcessGroup, check_ports_free # noqa: E402
|
||||
from devutil import ( # noqa: E402
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
ready,
|
||||
setup_cli,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||
@@ -60,35 +64,6 @@ SITE_ADDR {
|
||||
"""
|
||||
|
||||
|
||||
def build_vite_cmd(vite_host: str, vite_port: int) -> list[str] | None:
|
||||
"""Build the Vite dev command, or None if not available."""
|
||||
devpath = Path(__file__).parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", devpath)
|
||||
return None
|
||||
|
||||
try:
|
||||
cmd = find_dev_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(str(e))
|
||||
return None
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
cmd.extend([f"--port={vite_port}", "--logLevel=silent"])
|
||||
if vite_host and vite_host != "localhost":
|
||||
cmd.append("--host" if vite_host == "0.0.0.0" else f"--host={vite_host}")
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_npm_install_cmd() -> list[str] | None:
|
||||
"""Build the npm install command, or None if not available."""
|
||||
try:
|
||||
return find_install_tool()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def build_caddyfile(origins: list[str], vite_port: int) -> str:
|
||||
"""Build a Caddyfile for the given origins."""
|
||||
caddyfile_parts = []
|
||||
@@ -181,22 +156,26 @@ async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Pr
|
||||
|
||||
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
"""Run the development server with all components."""
|
||||
# Parse Vite endpoint
|
||||
endpoints = parse_endpoint(args.listen, DEFAULT_VITE_PORT)
|
||||
ep = endpoints[0]
|
||||
|
||||
if "uds" in ep:
|
||||
logger.warning("Unix sockets are not supported for Vite frontend")
|
||||
reporoot = Path(__file__).parent.parent
|
||||
frontend_path = reporoot / "frontend"
|
||||
if not (frontend_path / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", frontend_path)
|
||||
raise SystemExit(1)
|
||||
|
||||
vite_host = ep["host"]
|
||||
vite_port = ep["port"]
|
||||
# Multiple endpoints means all-interfaces (:port syntax)
|
||||
if len(endpoints) > 1:
|
||||
vite_host = "0.0.0.0"
|
||||
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
|
||||
backurl, paskia = setup_cli("paskia", f"localhost:{BACKEND_PORT}", BACKEND_PORT)
|
||||
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
backend_url = f"http://localhost:{BACKEND_PORT}"
|
||||
# Extract vite port for Caddy config
|
||||
vite_port = int(viteurl.rsplit(":", 1)[1])
|
||||
|
||||
# Build paskia command with options
|
||||
paskia.extend(["--rp-id", args.rp_id])
|
||||
if args.auth_host:
|
||||
paskia.extend(["--auth-host", args.auth_host])
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
paskia.extend(["--origin", origin])
|
||||
paskia.extend(remaining)
|
||||
|
||||
# Compute origins for Caddy
|
||||
caddy_origins = []
|
||||
@@ -217,30 +196,13 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
seen = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
|
||||
# Check ports are free before starting
|
||||
await check_ports_free(vite_url, backend_url)
|
||||
|
||||
# Set environment for subprocesses
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = vite_url
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else vite_url
|
||||
os.environ["PASKIA_FRONTEND_URL"] = viteurl
|
||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
|
||||
# Build commands
|
||||
frontend_path = Path(__file__).parent.parent / "frontend"
|
||||
vite_cmd = build_vite_cmd(vite_host, vite_port)
|
||||
install_cmd = build_npm_install_cmd()
|
||||
|
||||
paskia_cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
|
||||
paskia_cmd.extend(["--rp-id", args.rp_id])
|
||||
if args.auth_host:
|
||||
paskia_cmd.extend(["--auth-host", args.auth_host])
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
paskia_cmd.extend(["--origin", origin])
|
||||
paskia_cmd.extend(remaining)
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
@@ -248,29 +210,11 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
|
||||
# Run npm install concurrently with backend startup
|
||||
if install_cmd and (frontend_path / "package.json").exists():
|
||||
npm_proc = await pg.spawn(*install_cmd, cwd=str(frontend_path))
|
||||
else:
|
||||
npm_proc = None
|
||||
|
||||
# Start paskia backend
|
||||
logger.info(">>> (devmode) %s", " ".join(paskia_cmd))
|
||||
paskia_proc = await asyncio.create_subprocess_exec(*paskia_cmd)
|
||||
pg._procs.append(paskia_proc)
|
||||
pg._cmds[paskia_proc.pid] = "paskia"
|
||||
|
||||
# Wait for npm install to complete before starting Vite
|
||||
if npm_proc:
|
||||
await pg.wait(npm_proc)
|
||||
|
||||
# Start Vite dev server
|
||||
if vite_cmd:
|
||||
await pg.spawn(*vite_cmd, cwd=str(frontend_path))
|
||||
else:
|
||||
logger.info(
|
||||
"Backend expects Vite at %s - start it manually if needed", vite_url
|
||||
)
|
||||
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*paskia)
|
||||
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
await pg.spawn(*vite, cwd=frontend_path)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ class ProcessGroup:
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None, env: dict | None = None
|
||||
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, env=env)
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
@@ -188,3 +188,24 @@ def setup_fastapi(
|
||||
"--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