Files
Spoolman/spoolman/client.py
Donkie b7772ca743 client_v2: fix base path for direct loads of non-prerendered routes
SvelteKit's adapter-static emits the SPA fallback (200.html) with absolute
asset paths (/_app/...) and a hardcoded base: "", since it can't know the
deploy base path at build time. Prerendered per-route documents use relative
paths and a runtime-computed base, so they work under any base path, but the
fallback is used for non-prerendered dynamic routes (/spool/show/<id>,
/filament/show/<id>) -- exactly the URLs printed into QR labels. Under a base
path those direct loads 404'd every asset and rendered a blank page.

Rewrite the fallback server-side using the base path the backend already
knows, mirroring the legacy-client asset rewrite: prefix /_app/ and /favicon
references and set the runtime base. Prerendered docs are still served
verbatim, and the legacy client is unaffected (its tweaked_html is always
produced when rewrite_asset_paths is on, so the file_response guard change is
equivalent there).
2026-07-25 17:49:11 +02:00

162 lines
7.5 KiB
Python

"""Functions for providing the client interface."""
import logging
import os
from collections.abc import MutableMapping
from pathlib import Path
from typing import Any, Union
from fastapi.staticfiles import StaticFiles
from starlette.datastructures import Headers
from starlette.responses import FileResponse, Response
from starlette.staticfiles import NotModifiedResponse
logger = logging.getLogger(__name__)
PathLike = Union[str, "os.PathLike[str]"]
Scope = MutableMapping[str, Any]
class SinglePageApplication(StaticFiles):
"""Serve a single page application.
Handles both the legacy React client and the new Svelte (SvelteKit) client:
- The legacy client references its assets with ``"./..."`` paths that must be
rewritten server-side to include the configured base path. Its SPA fallback
document is ``index.html``. Enable this with ``rewrite_asset_paths=True``.
- The Svelte client's prerendered per-route documents (e.g. ``index.html``,
``locations.html``) use relative asset paths and compute the deploy base at
runtime, so the browser resolves everything against the current URL and they
are served verbatim under any base path. Its SPA fallback document
(``200.html``), however, is emitted with *absolute* asset paths (``/_app/...``)
and a hardcoded ``base: ""`` — SvelteKit cannot know the deploy base path at
build time. When a base path is configured we rewrite that fallback so direct
loads of non-prerendered routes (e.g. ``/spool/show/<id>``, the target of
printed QR labels) boot correctly. This uses ``rewrite_asset_paths=False`` (the
fallback fixup is applied automatically when a base path is set).
"""
def __init__(
self,
directory: str,
base_path: str,
*,
fallback_document: str = "index.html",
rewrite_asset_paths: bool = True,
) -> None:
"""Construct."""
super().__init__(directory=directory, packages=None, html=True, check_dir=True)
self.base_path = base_path.removeprefix("/")
self.fallback_document = fallback_document
self.rewrite_asset_paths = rewrite_asset_paths
# NB: don't touch self.html — StaticFiles uses it as the HTML-mode boolean flag.
self.tweaked_html = ""
if self.rewrite_asset_paths:
self.load_and_tweak_index_file()
elif self.base_path:
# Svelte client: the prerendered documents already work under any base
# path; only the SPA fallback needs its absolute asset paths fixed up.
self.load_and_rewrite_fallback_base_path()
def load_and_tweak_index_file(self) -> None:
"""Load the fallback document and tweak it by replacing all asset paths."""
# Open the fallback document located in self.directory
if not self.directory:
return
with (Path(self.directory) / self.fallback_document).open() as f:
html = f.read()
# Replace all paths that start with "./" with f"/{self.base_path}"
base_path = "/" if len(self.base_path.strip()) == 0 else f"/{self.base_path}/"
self.tweaked_html = html.replace('"./', f'"{base_path}')
def load_and_rewrite_fallback_base_path(self) -> None:
"""Rewrite the SvelteKit SPA fallback so it boots under a base path.
SvelteKit's adapter-static emits the fallback document (``200.html``) with
absolute asset references (``/_app/...``, ``/favicon...``) and a hardcoded
``base: ""`` — it cannot know the operator's base path at build time. The
prerendered per-route documents use relative paths and a runtime-computed
base, so they work unchanged; only this fallback needs fixing up so that
direct loads of non-prerendered routes (e.g. ``/spool/show/<id>``, the target
of printed QR labels) resolve their assets and API base under the base path.
Only called when a base path is configured (``self.base_path`` non-empty);
without one the fallback is served verbatim.
"""
if not self.directory:
return
with (Path(self.directory) / self.fallback_document).open() as f:
html = f.read()
prefix = f"/{self.base_path}"
# `"/_app/` covers module preloads, stylesheets and the inline `import("/_app/...")`
# bootstrap calls; `"/favicon` covers the icon link. `base: ""` is SvelteKit's
# runtime base, which drives client-side routing and the derived API URL.
self.tweaked_html = (
html.replace('"/_app/', f'"{prefix}/_app/')
.replace('"/favicon', f'"{prefix}/favicon')
.replace('base: ""', f'base: "{prefix}"')
)
def file_response(
self,
full_path: PathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
"""Overriden default file_response.
Works the same way, but when the requested file is the fallback document and
we hold a rewritten version of it, we return that instead of the on-disk file.
That covers the legacy client (all asset paths rewritten with the base path)
and the Svelte client under a base path (fallback asset paths and runtime base
fixed up). The Svelte client's prerendered per-route documents are not the
fallback document, so they are always served verbatim.
"""
request_headers = Headers(scope=scope)
# If full_path points to the fallback document and we have a rewritten
# version of it, return that instead of the raw file.
if self.tweaked_html and Path(full_path).name == self.fallback_document:
return Response(self.tweaked_html, status_code=status_code, media_type="text/html")
# Starlette >=1.x dropped the `method` kwarg from FileResponse; it now derives
# HEAD handling from the request scope internally when the response is called.
response = FileResponse(full_path, status_code=status_code, stat_result=stat_result)
if self.is_not_modified(response.headers, request_headers):
return NotModifiedResponse(response.headers)
return response
def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
"""Return the fallback document if the requested file cannot be found."""
# The ASGI mount (app.mount(base_path, ...)) already strips the base path
# from the request, so `path` arrives base-relative. Do NOT strip base_path
# again here as a raw string prefix — that corrupts asset names that merely
# start with it (e.g. "spoolman.svg" under base "spool" -> "man.svg").
path = path.removeprefix("/")
full_path, stat_result = super().lookup_path(path)
if stat_result is None:
ext = Path(path).suffix
# Check if user is looking for some specific non-document file
if len(ext) > 1 and ext != ".html":
# If so, return 404
return ("", None)
# The Svelte client prerenders a document per route (e.g. "/locations" ->
# "locations.html"). Serve that if it exists so the initial paint is correct.
if path and not path.endswith(".html"):
route_full, route_stat = super().lookup_path(path + ".html")
if route_stat is not None:
return (route_full, route_stat)
# Otherwise, they did look for a document, lead them to the fallback document
return super().lookup_path(self.fallback_document)
return (full_path, stat_result)