From 95b3e55bc7e44c735dd70addd97285ff14e2c424 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 26 Oct 2025 21:46:23 -0600 Subject: [PATCH] Fixed Engine Flask Server Accessibility --- .vscode/tasks.json | 30 +++++ Borealis.ps1 | 56 +++++++-- Data/Engine/CODE_MIGRATION_TRACKER.md | 10 +- Data/Engine/Unit_Tests/conftest.py | 10 +- Data/Engine/Unit_Tests/test_core_api.py | 9 ++ Data/Engine/Unit_Tests/test_web_ui.py | 23 ++++ Data/Engine/bootstrapper.py | 151 ++++++++++++++++++++++-- Data/Engine/config.py | 49 +++++--- Data/Engine/services/API/__init__.py | 24 +++- Data/Engine/services/WebUI/__init__.py | 87 ++++++++++++-- 10 files changed, 392 insertions(+), 57 deletions(-) create mode 100644 Data/Engine/Unit_Tests/test_core_api.py create mode 100644 Data/Engine/Unit_Tests/test_web_ui.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 60c5708..f016f68 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -31,6 +31,36 @@ }, "problemMatcher": [] }, + { + "label": "Borealis - Engine (Production)", + "type": "shell", + "command": "powershell.exe", + "args": [ + "-ExecutionPolicy", "Bypass", + "-File", "${workspaceFolder}/Borealis.ps1", + "-EngineProduction" + ], + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "Borealis - Engine (Dev)", + "type": "shell", + "command": "powershell.exe", + "args": [ + "-ExecutionPolicy", "Bypass", + "-File", "${workspaceFolder}/Borealis.ps1", + "-EngineDev" + ], + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": [] + }, { "label": "Borealis - Agent - Deploy", "type": "shell", diff --git a/Borealis.ps1 b/Borealis.ps1 index 8f33225..e3b48da 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -10,6 +10,8 @@ param( [switch]$Flask, [switch]$Quick, [switch]$EngineTests, + [switch]$EngineProduction, + [switch]$EngineDev, [string]$InstallerCode = '' ) @@ -17,6 +19,7 @@ param( $choice = $null $modeChoice = $null $agentSubChoice = $null +$engineModeChoice = $null $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent @@ -48,6 +51,16 @@ if ($Vite -and $Flask) { exit 1 } +if ($EngineProduction -and $EngineDev) { + Write-Host "Cannot combine -EngineProduction and -EngineDev." -ForegroundColor Red + exit 1 +} + +if (($EngineProduction -or $EngineDev) -and ($Server -or $Agent)) { + Write-Host "Engine automation switches cannot be combined with -Server or -Agent." -ForegroundColor Red + exit 1 +} + if ($Server) { # Auto-select main menu option for Server when -Server flag is provided $choice = '1' @@ -60,6 +73,10 @@ if ($Server) { 'launch' { $agentSubChoice = '4' } default { $agentSubChoice = '1' } } +} elseif ($EngineProduction -or $EngineDev) { + $choice = '5' + if ($EngineProduction) { $engineModeChoice = '1' } + if ($EngineDev) { $engineModeChoice = '3' } } if ($Server) { @@ -1078,7 +1095,11 @@ switch ($choice) { foreach ($tool in @($pythonExe, $nodeExe, $npmCmd, $npxCmd)) { if (-not (Test-Path $tool)) { Write-Host "`r$($symbols.Fail) Bundled executable not found at '$tool'." -ForegroundColor Red; exit 1 } } - $env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH + $nodeDir = Split-Path $nodeExe + $env:BOREALIS_NODE_DIR = $nodeDir + $env:BOREALIS_NPM_CMD = $npmCmd + $env:BOREALIS_NPX_CMD = $npxCmd + $env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH if (-not $modeChoice) { Write-Host " " @@ -1315,14 +1336,22 @@ switch ($choice) { exit 1 } } - $env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH + $nodeDir = Split-Path $nodeExe + $env:BOREALIS_NODE_DIR = $nodeDir + $env:BOREALIS_NPM_CMD = $npmCmd + $env:BOREALIS_NPX_CMD = $npxCmd + $env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH - Write-Host " " - Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow - Write-Host " 1) Build & Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan - Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan - Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan - $engineModeChoice = Read-Host "Enter choice [1/2/3]" + if (-not $engineModeChoice) { + Write-Host " " + Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow + Write-Host " 1) Build & Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan + Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan + Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan + $engineModeChoice = Read-Host "Enter choice [1/2/3]" + } else { + Write-Host "Auto-selecting Borealis Engine mode option $engineModeChoice." -ForegroundColor DarkYellow + } $engineOperationMode = "production" $engineImmediateLaunch = $false @@ -1347,7 +1376,7 @@ switch ($choice) { $previousEngineMode = $env:BOREALIS_ENGINE_MODE $previousEnginePort = $env:BOREALIS_ENGINE_PORT $env:BOREALIS_ENGINE_MODE = $engineOperationMode - $env:BOREALIS_ENGINE_PORT = "5001" + $env:BOREALIS_ENGINE_PORT = "5000" Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green Write-Host "====================================================================================" Write-Host "$($symbols.Running) Engine Socket Server Started..." @@ -1448,10 +1477,13 @@ switch ($choice) { $webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination if (Test-Path $webUIDestinationAbsolute) { Push-Location $webUIDestinationAbsolute - if ($engineOperationMode -eq "developer") { $viteSubCommand = "dev" } else { $viteSubCommand = "build" } $certRoot = Join-Path $scriptDir 'Certificates\Server' Ensure-EngineTlsMaterial -PythonPath $venvPython -CertificateRoot $certRoot - Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", $viteSubCommand) + if ($engineOperationMode -eq "developer") { + Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", "dev") + } else { + & $npmCmd run build + } Pop-Location } } @@ -1462,7 +1494,7 @@ switch ($choice) { $previousEngineMode = $env:BOREALIS_ENGINE_MODE $previousEnginePort = $env:BOREALIS_ENGINE_PORT $env:BOREALIS_ENGINE_MODE = $engineOperationMode - $env:BOREALIS_ENGINE_PORT = "5001" + $env:BOREALIS_ENGINE_PORT = "5000" Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green Write-Host "====================================================================================" Write-Host "$($symbols.Running) Engine Socket Server Started..." diff --git a/Data/Engine/CODE_MIGRATION_TRACKER.md b/Data/Engine/CODE_MIGRATION_TRACKER.md index 112a0e1..b0b332c 100644 --- a/Data/Engine/CODE_MIGRATION_TRACKER.md +++ b/Data/Engine/CODE_MIGRATION_TRACKER.md @@ -32,11 +32,11 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [x] Replace legacy API routes with Engine-provided blueprints gated by a flag. - [x] Emit transitional logging when Engine handles requests. - [ ] **Stage 6 — Plan WebUI migration** - - [ ] Move static/template handling into Data/Engine/services/WebUI. + - [x] Move static/template handling into Data/Engine/services/WebUI. - [x] Ensure that data from /Data/Server/WebUI is copied into /Engine/web-interface during engine Deployment via Borealis.ps1 - - [ ] Preserve TLS-aware URL generation and caching. + - [x] Preserve TLS-aware URL generation and caching. - [ ] Add migration switch in the legacy server for WebUI delegation. - - [ ] Extend tests to cover critical WebUI routes. + - [x] Extend tests to cover critical WebUI routes. - [ ] **Stage 7 — Plan WebSocket migration** - [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket. - [ ] Provide register_realtime hook for the Engine factory. @@ -44,5 +44,5 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [ ] Update legacy server to consume Engine WebSocket registration. ## Current Status -- **Stage:** Stage 5 — Bridge the legacy server to Engine APIs (completed) -- **Active Task:** Awaiting next stage instructions. +- **Stage:** Stage 6 — Plan WebUI migration +- **Active Task:** Prepare legacy WebUI delegation switch (pending approval to touch legacy server). diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py index 9256826..bd34bca 100644 --- a/Data/Engine/Unit_Tests/conftest.py +++ b/Data/Engine/Unit_Tests/conftest.py @@ -117,6 +117,13 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ log_path = logs_dir / "server.log" error_log_path = logs_dir / "error.log" + static_dir = tmp_path / "static" + static_dir.mkdir(parents=True, exist_ok=True) + (static_dir / "index.html").write_text("Engine Test UI", encoding="utf-8") + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + (assets_dir / "example.txt").write_text("asset", encoding="utf-8") + config = { "DATABASE_PATH": str(db_path), "TLS_CERT_PATH": str(cert_path), @@ -124,7 +131,8 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ "TLS_BUNDLE_PATH": str(bundle_path), "LOG_FILE": str(log_path), "ERROR_LOG_FILE": str(error_log_path), - "API_GROUPS": ("tokens", "enrollment"), + "STATIC_FOLDER": str(static_dir), + "API_GROUPS": ("core", "tokens", "enrollment"), } app, _socketio, _context = create_app(config) diff --git a/Data/Engine/Unit_Tests/test_core_api.py b/Data/Engine/Unit_Tests/test_core_api.py new file mode 100644 index 0000000..24366e4 --- /dev/null +++ b/Data/Engine/Unit_Tests/test_core_api.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +def test_health_endpoint(engine_harness): + client = engine_harness.app.test_client() + response = client.get("/health") + assert response.status_code == 200 + payload = response.get_json() + assert payload == {"status": "ok"} diff --git a/Data/Engine/Unit_Tests/test_web_ui.py b/Data/Engine/Unit_Tests/test_web_ui.py new file mode 100644 index 0000000..fb24788 --- /dev/null +++ b/Data/Engine/Unit_Tests/test_web_ui.py @@ -0,0 +1,23 @@ +from __future__ import annotations + + +def test_web_ui_root_serves_index(engine_harness): + client = engine_harness.app.test_client() + response = client.get("/") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "Engine Test UI" in body + + +def test_web_ui_serves_static_assets(engine_harness): + client = engine_harness.app.test_client() + response = client.get("/assets/example.txt") + assert response.status_code == 200 + assert response.get_data(as_text=True) == "asset" + + +def test_web_ui_spa_fallback(engine_harness): + client = engine_harness.app.test_client() + response = client.get("/devices") + assert response.status_code == 200 + assert "Engine Test UI" in response.get_data(as_text=True) diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index c1e8a49..a62fa75 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -2,7 +2,7 @@ The bootstrapper assembles configuration via :func:`Data.Engine.config.load_runtime_config` before delegating to :func:`Data.Engine.server.create_app`. It mirrors the -legacy server defaults by binding to ``0.0.0.0:5001`` and honouring the +legacy server defaults by binding to ``0.0.0.0:5000`` and honouring the ``BOREALIS_ENGINE_*`` environment overrides for bind host/port. """ @@ -11,14 +11,16 @@ from __future__ import annotations import logging import os import shutil +import subprocess +import time from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional from .server import EngineContext, create_app DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = 5001 +DEFAULT_PORT = 5000 def _project_root() -> Path: @@ -37,16 +39,34 @@ def _project_root() -> Path: def _build_runtime_config() -> Dict[str, Any]: + api_groups_override = os.environ.get("BOREALIS_ENGINE_API_GROUPS") + if api_groups_override: + api_groups: Any = api_groups_override + else: + api_groups = ("core", "tokens", "enrollment") + return { "HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST), "PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)), "TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"), "TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"), "TLS_BUNDLE_PATH": os.environ.get("BOREALIS_TLS_BUNDLE"), + "API_GROUPS": api_groups, } -def _stage_web_interface_assets(logger: logging.Logger) -> None: +def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, force: bool = False) -> Path: + """Ensure Engine web interface assets are staged and return the staging root.""" + + if logger is None: + logger = logging.getLogger("borealis.engine.bootstrap") + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s")) + logger.addHandler(handler) + logger.propagate = False + logger.setLevel(logging.INFO) + project_root = _project_root() engine_web_root = project_root / "Engine" / "web-interface" legacy_source = project_root / "Data" / "Server" / "WebUI" @@ -56,20 +76,114 @@ def _stage_web_interface_assets(logger: logging.Logger) -> None: f"Engine web interface source missing: {legacy_source}" ) + index_path = engine_web_root / "index.html" + if engine_web_root.exists() and index_path.is_file() and not force: + logger.info("Engine web interface already staged at %s; skipping copy.", engine_web_root) + return engine_web_root + if engine_web_root.exists(): shutil.rmtree(engine_web_root) shutil.copytree(legacy_source, engine_web_root) - index_path = engine_web_root / "index.html" if not index_path.is_file(): raise RuntimeError( f"Engine web interface staging failed; missing {index_path}" ) - logger.info( - "Engine web interface staged from %s to %s", legacy_source, engine_web_root - ) + logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root) + return engine_web_root + + +def _determine_static_folder(staging_root: Path) -> str: + for candidate in (staging_root / "build", staging_root / "dist", staging_root): + if candidate.is_dir(): + return str(candidate) + return str(staging_root) + + +def _resolve_npm_executable() -> str: + env_cmd = os.environ.get("BOREALIS_NPM_CMD") + if env_cmd: + candidate = Path(env_cmd).expanduser() + if candidate.is_file(): + return str(candidate) + + node_dir = os.environ.get("BOREALIS_NODE_DIR") + if node_dir: + candidate = Path(node_dir) / "npm.cmd" + if candidate.is_file(): + return str(candidate) + candidate = Path(node_dir) / "npm.exe" + if candidate.is_file(): + return str(candidate) + candidate = Path(node_dir) / "npm" + if candidate.is_file(): + return str(candidate) + + if os.name == "nt": + return "npm.cmd" + return "npm" + + +def _run_npm(args: list[str], cwd: Path, logger: logging.Logger) -> None: + command = [_resolve_npm_executable()] + args + logger.info("Running npm command: %s", " ".join(command)) + start = time.time() + try: + completed = subprocess.run(command, cwd=str(cwd), capture_output=True, text=True, check=False) + except FileNotFoundError as exc: + raise RuntimeError("npm executable not found; ensure Node.js dependencies are installed.") from exc + duration = time.time() - start + if completed.returncode != 0: + logger.error( + "npm command failed (%ss): %s\nstdout: %s\nstderr: %s", + f"{duration:.2f}", + " ".join(command), + completed.stdout.strip(), + completed.stderr.strip(), + ) + raise RuntimeError(f"npm command {' '.join(command)} failed with exit code {completed.returncode}") + stdout = completed.stdout.strip() + if stdout: + logger.debug("npm stdout: %s", stdout) + stderr = completed.stderr.strip() + if stderr: + logger.debug("npm stderr: %s", stderr) + logger.info("npm command completed in %.2fs", duration) + + +def _ensure_web_ui_build(staging_root: Path, logger: logging.Logger, *, mode: str) -> str: + package_json = staging_root / "package.json" + node_modules = staging_root / "node_modules" + build_dir = staging_root / "build" + needs_install = not node_modules.is_dir() + needs_build = not build_dir.is_dir() + + if not needs_build and package_json.is_file(): + build_index = build_dir / "index.html" + if build_index.is_file(): + needs_build = build_index.stat().st_mtime < package_json.stat().st_mtime + else: + needs_build = True + + if mode == "developer": + logger.info("Engine launched in developer mode; skipping WebUI build step.") + return _determine_static_folder(staging_root) + + if not package_json.is_file(): + logger.warning("WebUI package.json not found at %s; continuing without rebuild.", package_json) + return _determine_static_folder(staging_root) + + if needs_install: + _run_npm(["install", "--silent", "--no-fund", "--audit=false"], staging_root, logger) + + if needs_build: + _run_npm(["run", "build"], staging_root, logger) + else: + logger.info("Existing WebUI build found at %s; reuse.", build_dir) + + return _determine_static_folder(staging_root) def _ensure_tls_material(context: EngineContext) -> None: @@ -137,13 +251,24 @@ def _prepare_tls_run_kwargs(context: EngineContext) -> Dict[str, Any]: def main() -> None: config = _build_runtime_config() + mode = os.environ.get("BOREALIS_ENGINE_MODE", "production").strip().lower() or "production" + try: + staging_root = _stage_web_interface_assets() + except Exception as exc: + logging.getLogger("borealis.engine.bootstrap").error( + "Failed to stage Engine web interface: %s", exc + ) + raise + + if staging_root: + bootstrap_logger = logging.getLogger("borealis.engine.bootstrap") + static_folder = _ensure_web_ui_build(staging_root, bootstrap_logger, mode=mode) + config.setdefault("STATIC_FOLDER", static_folder) + app, socketio, context = create_app(config) - try: - _stage_web_interface_assets(context.logger) - except Exception as exc: - context.logger.error("Failed to stage Engine web interface: %s", exc) - raise + if staging_root: + context.logger.info("Engine WebUI assets ready at %s", config["STATIC_FOLDER"]) host = config.get("HOST", DEFAULT_HOST) port = int(config.get("PORT", DEFAULT_PORT)) diff --git a/Data/Engine/config.py b/Data/Engine/config.py index c743b3a..3336985 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -47,7 +47,26 @@ except Exception: # pragma: no-cover - Engine configuration still works without ENGINE_DIR = Path(__file__).resolve().parent -PROJECT_ROOT = ENGINE_DIR.parent.parent + + +def _discover_project_root() -> Path: + """Locate the project root by searching for Borealis.ps1 or using overrides.""" + + env_override = os.environ.get("BOREALIS_PROJECT_ROOT") + if env_override: + env_path = Path(env_override).expanduser().resolve() + if env_path.is_dir(): + return env_path + + current = ENGINE_DIR + for candidate in (current, *current.parents): + if (candidate / "Borealis.ps1").is_file(): + return candidate + + return ENGINE_DIR.parent.parent + + +PROJECT_ROOT = _discover_project_root() DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db" LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine" LOG_FILE_PATH = LOG_ROOT / "engine.log" @@ -66,26 +85,28 @@ def _ensure_parent(path: Path) -> None: def _resolve_static_folder() -> str: candidate_roots = [ PROJECT_ROOT / "Engine" / "web-interface", + PROJECT_ROOT / "web-interface", + ENGINE_DIR.parent / "Engine" / "web-interface", + ENGINE_DIR.parent / "web-interface", ENGINE_DIR / "web-interface", PROJECT_ROOT / "Data" / "Server" / "web-interface", ] - candidates = [] + resolved_roots: List[Path] = [] for root in candidate_roots: - absolute_root = root.resolve() - candidates.extend( - [ - absolute_root / "build", - absolute_root / "dist", - absolute_root, - ] - ) + absolute_root = root.expanduser().resolve() + if absolute_root not in resolved_roots: + resolved_roots.append(absolute_root) - for candidate in candidates: - if candidate.is_dir(): - return str(candidate) + for root in resolved_roots: + for candidate in (root / "build", root / "dist", root): + if candidate.is_dir(): + return str(candidate) - return str(candidates[0]) + if resolved_roots: + return str(resolved_roots[0]) + + return str((PROJECT_ROOT / "Engine" / "web-interface").resolve()) def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]: diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index 3fee35e..0e55166 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Iterable, Mapping, Optional, Sequence -from flask import Flask +from flask import Blueprint, Flask, jsonify from Modules.auth import jwt_service as jwt_service_module from Modules.auth.dpop import DPoPValidator @@ -191,18 +191,36 @@ _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]] } +def _register_core(app: Flask, context: EngineContext) -> None: + """Register core utility endpoints that do not require legacy adapters.""" + + blueprint = Blueprint("engine_core", __name__) + + @blueprint.route("/health", methods=["GET"]) + def health() -> Any: + return jsonify({"status": "ok"}) + + app.register_blueprint(blueprint) + context.logger.info("Engine registered API group 'core'.") + + def register_api(app: Flask, context: EngineContext) -> None: """Register Engine API blueprints based on the enabled groups.""" enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS normalized = [group.strip().lower() for group in enabled_groups if group] - adapters = LegacyServiceAdapters(context) + adapters: Optional[LegacyServiceAdapters] = None for group in normalized: + if group == "core": + _register_core(app, context) + continue + + if adapters is None: + adapters = LegacyServiceAdapters(context) registrar = _GROUP_REGISTRARS.get(group) if registrar is None: context.logger.info("Engine API group '%s' is not implemented; skipping.", group) continue registrar(app, adapters) context.logger.info("Engine registered API group '%s'.", group) - diff --git a/Data/Engine/services/WebUI/__init__.py b/Data/Engine/services/WebUI/__init__.py index 9d39b91..0ad89ad 100644 --- a/Data/Engine/services/WebUI/__init__.py +++ b/Data/Engine/services/WebUI/__init__.py @@ -1,17 +1,86 @@ -"""WebUI service stubs for the Borealis Engine runtime. - -The future WebUI migration will centralise static asset serving, template -rendering, and dev-server proxying here. Stage 1 keeps the placeholder so the -application factory can stub out registration calls. -""" +"""WebUI static asset handling for the Borealis Engine runtime.""" from __future__ import annotations -from flask import Flask +import logging +from pathlib import Path +from typing import Optional + +from flask import Blueprint, Flask, request, send_from_directory +from werkzeug.exceptions import NotFound from ...server import EngineContext +_WEBUI_BLUEPRINT_NAME = "engine_webui" + + +def _resolve_static_root(app: Flask, context: EngineContext) -> Optional[Path]: + static_folder = app.static_folder + if not static_folder: + context.logger.error("Engine WebUI static folder is not configured.") + return None + + static_path = Path(static_folder).resolve() + if not static_path.is_dir(): + context.logger.error("Engine WebUI static folder missing: %s", static_path) + return None + + index_path = static_path / "index.html" + if not index_path.is_file(): + context.logger.error("Engine WebUI missing index.html at %s", index_path) + return None + + return static_path + + +def _register_spa_routes(app: Flask, static_root: Path, logger: logging.Logger) -> None: + blueprint = Blueprint( + _WEBUI_BLUEPRINT_NAME, + __name__, + static_folder=str(static_root), + static_url_path="", + ) + + def send_index(): + return send_from_directory(str(static_root), "index.html") + + @blueprint.route("/", defaults={"requested_path": ""}) + @blueprint.route("/") + def spa_entry(requested_path: str) -> object: + if requested_path: + try: + return send_from_directory(str(static_root), requested_path) + except NotFound: + logger.debug("Engine WebUI asset not found: %s", requested_path) + return send_index() + + app.register_blueprint(blueprint) + + if getattr(app, "_engine_webui_fallback_installed", False): + return + + def _spa_fallback(error): + request_path = (request.path or "").strip() + if request_path.startswith("/api") or request_path.startswith("/socket.io"): + return error + if "." in Path(request_path).name: + return error + if request.method not in {"GET", "HEAD"}: + return error + try: + return send_index() + except Exception: + return error + + app.register_error_handler(404, _spa_fallback) + setattr(app, "_engine_webui_fallback_installed", True) + def register_web_ui(app: Flask, context: EngineContext) -> None: - """Placeholder hook for WebUI route registration.""" + """Register static asset routes for the Engine WebUI.""" - context.logger.debug("Engine WebUI services are not yet implemented.") + static_root = _resolve_static_root(app, context) + if static_root is None: + return + + _register_spa_routes(app, static_root, context.logger) + context.logger.info("Engine WebUI registered static assets from %s", static_root)