Add spa flag for static deployments; fix Hugo navigation

Static (manager: caddy) deployments always emitted the SPA fallback
`try_files {path} /index.html`, which serves the root index.html for
every unmatched path. That's correct for React/Vite apps but swallows a
multi-page content site's in-page links back to the homepage, so a Hugo
site (payne.io) couldn't navigate off its landing page.

Add an explicit `spa: bool` on CaddyDeployment (default True, preserving
existing SPA behavior). When False, the gateway serves plain file_server
— resolving directory indexes (/posts/ -> /posts/index.html) and 404ing
missing paths. Threaded through the registry snapshot + mesh and the
route computation so both the internal wildcard and public apex blocks
honor it. `castle program create --stack hugo` now sets spa: false.
This commit is contained in:
2026-07-12 23:41:47 -07:00
parent 1767a0a304
commit d152e79cee
6 changed files with 58 additions and 24 deletions

View File

@@ -92,6 +92,9 @@ class Deployment:
# For `static` runner services: the absolute dir the gateway file_servers.
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
static_root: str | None = None
# For `static` routes: SPA fallback (True, default) vs content-site serving
# (False, Hugo/multi-page). See CaddyDeployment.spa.
spa: bool = True
base_url: str | None = None
schedule: str | None = None
managed: bool = False
@@ -192,6 +195,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"),
spa=comp_data.get("spa", True),
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
@@ -273,6 +277,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["tcp_port"] = comp.tcp_port
if comp.static_root:
entry["static_root"] = comp.static_root
# Only emit when non-default (content-site) — default-True omission keeps
# existing registries byte-identical and matches the load-side default.
if not comp.spa:
entry["spa"] = comp.spa
if comp.base_url:
entry["base_url"] = comp.base_url
if comp.schedule: