From d152e79cee1df06125288e66754dd60bd99b153f Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Jul 2026 23:41:47 -0700 Subject: [PATCH] Add spa flag for static deployments; fix Hugo navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli/src/castle_cli/commands/create.py | 6 +++ core/src/castle_core/deploy.py | 1 + core/src/castle_core/generators/caddyfile.py | 53 +++++++++++--------- core/src/castle_core/manifest.py | 7 +++ core/src/castle_core/registry.py | 8 +++ docs/stacks/hugo.md | 7 ++- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 173e5fd..957dfbc 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -41,6 +41,11 @@ STACK_BUILD_OUTPUTS: dict[str, str] = { "hugo": "public", } +# Stacks whose static output is a multi-page content site (not a single-page app): +# the gateway resolves directory indexes and 404s missing paths instead of falling +# back to the root index.html. See CaddyDeployment.spa. +CONTENT_SITE_STACKS: set[str] = {"hugo"} + # Substrate a stack's apps depend on — seeded as a deployment `requires` at creation # so the relationship graph shows it. This keeps `stack` uncoupled from the runtime # model: the stack declares the edge once here; the graph only ever reads the encoded @@ -142,6 +147,7 @@ def run_create(args: argparse.Namespace) -> int: manager="caddy", program=name, root=static_root or "dist", + spa=stack not in CONTENT_SITE_STACKS, description=description, requires=seeded_requires, ) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 33e9a6f..a813095 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -743,6 +743,7 @@ def _build_deployed( public=bool(dep.public), public_host=(dep.public_host if dep.public else None), static_root=static_root, + spa=dep.spa, managed=False, enabled=dep.enabled, requires=_registry_requires(dep), diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 1887e08..756c6e6 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -44,6 +44,8 @@ class GatewayRoute: # Optional exact public-facing FQDN override (apex allowed). When set, the # public name is this instead of the derived
.. public_host: str | None = None + # static routes only: SPA fallback (True) vs content-site serving (False). + spa: bool = True @property def is_host(self) -> bool: @@ -70,15 +72,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets: def _local_routes( config: CastleConfig | None, registry: NodeRegistry -) -> list[tuple[str, str, str, bool, str | None]]: - """Each local deployment's route as ``(name, kind, target, public, public_host)``. +) -> list[tuple[str, str, str, bool, str | None, bool]]: + """Each local deployment's route as ``(name, kind, target, public, public_host, spa)``. ``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or ``proxy`` (a proxied systemd process). Prefers ``castle.yaml`` (``config.deployments``) so a regenerated Caddyfile reflects the current spec; falls back to the deployed registry snapshot when config isn't available. """ - out: list[tuple[str, str, str, bool, str | None]] = [] + out: list[tuple[str, str, str, bool, str | None, bool]] = [] if config is not None: # Only HTTP-exposed kinds route: static (file-serve) and service (proxy). # jobs/tools/references never do. @@ -91,21 +93,21 @@ def _local_routes( src = _program_source(config, dep.program) if src is not None: pub_host = dep.public_host if dep.public else None - out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host)) + out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host, dep.spa)) elif kind == "service" and isinstance(dep, SystemdDeployment): expose, port, base_url = service_proxy_targets(name, dep) if expose and (port or base_url): pub_host = dep.public_host if dep.public else None - out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host)) + out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host, True)) return out # No config → route from the deployed registry snapshot. for _kind, name, d in registry.all(): if not d.enabled: continue if d.static_root: - out.append((name, "static", d.static_root, d.public, d.public_host)) + out.append((name, "static", d.static_root, d.public, d.public_host, d.spa)) elif d.subdomain and (d.port or d.base_url): - out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host)) + out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host, True)) return out @@ -146,8 +148,8 @@ def compute_routes( # built dir; everything else that's exposed reverse-proxies its port/base_url. # (Static frontends are `runner: static` services now — no separate program # branch, so routing derives from one place.) - for name, kind, target, is_public, pub_host in _local_routes(config, registry): - routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host)) + for name, kind, target, is_public, pub_host, spa in _local_routes(config, registry): + routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa)) if remote_registries: routes.extend(_remote_routes(config, registry, remote_registries)) @@ -229,21 +231,30 @@ def _host_remote_block(label: str, host: str, target: str) -> list[str]: ] -def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]: - """A host matcher that file-serves a frontend's dist (with SPA fallback).""" +def _static_serve_lines(serve_dir: str, spa: bool, indent: str) -> list[str]: + """The `root`/`file_server` body for a static site. SPA sites get a fallback to + the root `index.html` (client-side routing); content sites (Hugo) get plain + `file_server`, which resolves directory indexes and 404s missing paths.""" + lines = [f"{indent}root * {serve_dir}"] + if spa: + lines.append(indent + "try_files {path} /index.html") + lines.append(f"{indent}file_server") + return lines + + +def _host_static_block(label: str, host: str, serve_dir: str, spa: bool) -> list[str]: + """A host matcher that file-serves a frontend's dist.""" matcher = f"@host_{label.replace('-', '_').replace('.', '_')}" return [ f" {matcher} host {host}", f" handle {matcher} {{", - f" root * {serve_dir}", - " try_files {path} /index.html", - " file_server", + *_static_serve_lines(serve_dir, spa, " "), " }", "", ] -def _public_site_block(host: str, kind: str, target: str) -> list[str]: +def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]: """A standalone Caddy site for a custom ``public_host`` (apex or another zone). Unlike the ``*.`` wildcard site, an apex/foreign host isn't @@ -251,11 +262,7 @@ def _public_site_block(host: str, kind: str, target: str) -> list[str]: host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's Cloudflare token can edit the host's zone.""" if kind == "static": - body = [ - f" root * {target}", - " try_files {path} /index.html", - " file_server", - ] + body = _static_serve_lines(target, spa, " ") elif kind == "remote": body = [ f" reverse_proxy {target} {{", @@ -334,7 +341,7 @@ def generate_caddyfile_from_registry( for r in routes: host = f"{r.address}.{domain}" if r.kind == "static": - lines += _host_static_block(r.name or r.address, host, r.target) + lines += _host_static_block(r.name or r.address, host, r.target, r.spa) elif r.kind == "remote": lines += _host_remote_block(r.name or r.address, host, r.target) else: @@ -359,7 +366,7 @@ def generate_caddyfile_from_registry( host = f"{r.address}.{public_domain}" label = f"{r.name or r.address}_pub" if r.kind == "static": - lines += _host_static_block(label, host, r.target) + lines += _host_static_block(label, host, r.target, r.spa) elif r.kind == "remote": lines += _host_remote_block(label, host, r.target) else: @@ -368,7 +375,7 @@ def generate_caddyfile_from_registry( lines.append("") for r in custom_pub: if r.public_host: # always true (custom_pub filter); narrows the type - lines += _public_site_block(r.public_host, r.kind, r.target) + lines += _public_site_block(r.public_host, r.kind, r.target, r.spa) # Redirect the bare gateway port to the dashboard subdomain. lines += [ f":{gw_port} {{", diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 9f3cc6b..e24c57e 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -513,6 +513,13 @@ class CaddyDeployment(DeploymentBase): manager: Literal["caddy"] root: str = "dist" + # Serving strategy. `True` (default) → SPA fallback: any unmatched path serves + # the root `index.html` so a client-side router (React/Vite) takes over. `False` + # → content-site serving: the gateway resolves directory indexes (`/posts/` → + # `/posts/index.html`) and 404s genuinely-missing paths — correct for Hugo and + # other multi-page static sites, where the SPA fallback would swallow every + # in-site link back to the homepage. + spa: bool = True # A static site is inherently served at its subdomain, so `reach` is # `internal` or `public` (never `off`). `public` = also project via the tunnel. reach: Reach = Reach.INTERNAL diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index fe05dc0..08764ce 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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: diff --git a/docs/stacks/hugo.md b/docs/stacks/hugo.md index 37c367c..8588161 100644 --- a/docs/stacks/hugo.md +++ b/docs/stacks/hugo.md @@ -13,7 +13,12 @@ How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle progra - Generator: Hugo (extended recommended — needed for SCSS/asset processing) - Build: `hugo --gc --minify` → `public/` -- Served: `manager: caddy` static deployment, in place at `.` +- Served: `manager: caddy` static deployment, in place at `.`, + with `spa: false` — the gateway resolves directory indexes (`/posts/` → + `/posts/index.html`) and 404s missing paths. (The default `spa: true` is a + single-page-app fallback that serves the root `index.html` for every unmatched + path — correct for React/Vite, but it swallows a Hugo site's in-page links back + to the homepage. `castle program create --stack hugo` sets `spa: false` for you.) - Package manager (only if a theme needs an asset pipeline): pnpm Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check,