Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,7 +77,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` / `register_csp_sources` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_csp_sources(registry)` lets a module whitelist external asset origins (`registry.add("style-src", "https://rsms.me")`) — fetch directives only, validated at boot. `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → CommitBeforeResponse → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.

**Database**: per-module `Base` via `create_module_base("<name>")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout.

Expand Down
104 changes: 104 additions & 0 deletions framework/hosting/simple_module_hosting/_inertia_cache.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
"""Keep the Inertia payload out of caches that answer page requests.

Every Inertia route serves one URL as two representations, chosen on the
request's ``X-Inertia`` header: an HTML document for a full page load, a JSON
payload for a client-side visit. Nothing in either response says so, which
leaves a cache free to store one and hand it back for the other. Visit a page
through the SPA, then open the same URL directly, and the browser can serve the
stored payload as the document — the visitor gets
``{"component":"...","props":{...}}`` where the page should be.

That only bites once a route marks itself cacheable, which a public-content
module reasonably does. The framework is what makes it unsafe:
:class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges
the signed-in user's ``auth`` block, their permission list and the menus their
roles resolve to into *every* Inertia payload. A route author choosing
``Cache-Control: public`` for their page content has no way to know that, so
the guarantee belongs here rather than in each module:

* **An Inertia payload is never stored.** ``private, no-store``, and the ETag
is dropped so no cache can revalidate its way back to a copy it should not
have kept. The cost is per-visit caching on client-side navigation, which was
never safe to take — those bytes are specific to one user.
* **Both representations declare ``Vary: X-Inertia``**, so a cache that honours
Vary keeps them in separate entries instead of inferring it from ``Accept``.

``Vary`` is added to the document only when the response is HTML, so static
assets and JSON APIs keep the validators and cache entries they had.

A module that wants its public page content cached should give the *document*
its own ``Cache-Control`` and an ETag that identifies the representation; this
middleware leaves that path alone and only governs the payload.
"""

from __future__ import annotations

from starlette.datastructures import MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send

_SCOPE_HTTP = "http"
_HEADER_INERTIA = b"x-inertia"

#: What an Inertia payload is allowed to say about its own cacheability.
PAYLOAD_CACHE_CONTROL = "private, no-store"

#: The request header that selects the representation, and therefore the one
#: caches must key on.
VARY_FIELD = "X-Inertia"


def add_vary(headers: MutableHeaders, field: str = VARY_FIELD) -> None:
"""Add a field to ``Vary``, keeping whatever is already listed."""
existing = headers.get("vary")
if not existing:
headers["vary"] = field
return
if any(part.strip().lower() == field.lower() for part in existing.split(",")):
return
headers["vary"] = f"{existing}, {field}"


def is_inertia_request(scope: Scope) -> bool:
"""Whether this request asked for the JSON representation.

Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly —
presence of the header, any value — rather than requiring it to equal
``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a
stricter check here would disagree with it: a request the renderer treats
as Inertia would sail through uncached, undoing the whole fix.
"""
return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ()))


def _is_html(headers: MutableHeaders) -> bool:
return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html"


class InertiaCacheMiddleware:
"""Stop an Inertia payload being cached as though it were the page."""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != _SCOPE_HTTP:
await self.app(scope, receive, send)
return

inertia = is_inertia_request(scope)

async def send_with_cache_rules(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
if inertia:
headers["cache-control"] = PAYLOAD_CACHE_CONTROL
del headers["etag"]
add_vary(headers)
elif _is_html(headers):
add_vary(headers)
await send(message)

await self.app(scope, receive, send_with_cache_rules)


__all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"]
86 changes: 86 additions & 0 deletions framework/hosting/simple_module_hosting/_inertia_json.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
"""Make Inertia's JSON branch encode props the way its HTML branch does.

``fastapi-inertia`` renders the same props two ways and configures only one of
them::

# full page load — honours InertiaConfig.json_encoder
json_string = json.dumps(page_data, cls=self._config.json_encoder)

# client-side visit — Starlette's JSONResponse, so plain json.dumps
return JSONResponse(content=await self._get_page_data(), ...)

``InertiaJsonEncoder`` exists precisely to run the payload through FastAPI's
``jsonable_encoder``, and the second branch never reaches it. The effect is a
route that works on a reload and 500s on every client-side visit, for any prop
the stdlib encoder cannot handle — ``Path``, ``Decimal``, ``UUID``, a dataclass,
an enum.

Settings → Modules is where this surfaced: it reflects each installed module's
pydantic settings back to the admin, and a module whose settings carry a
``Path`` field (``PagebuilderSettings.media_root``) puts a ``PosixPath`` in the
payload::

TypeError: Object of type PosixPath is not JSON serializable

Fixing it here rather than in each module is deliberate. A module putting a
rich value in props is not doing anything wrong — the HTML branch has always
accepted it — so the asymmetry is the defect, and closing it at the point of
serialisation covers every module and every prop type at once.

Applied by wrapping the dependency published on ``app.state.inertia_dependency``,
which ``inertia_deps.get_inertia`` calls once per request. The replacement binds
to the instance, so the library's class is untouched and any other construction
path keeps the stock behaviour.
"""

from __future__ import annotations

import logging
from typing import Any

from fastapi.encoders import jsonable_encoder
from starlette.responses import JSONResponse

logger = logging.getLogger(__name__)

#: The headers upstream's ``_render_json`` sets, reproduced so the wrap is a
#: pure encoder change. ``InertiaCacheMiddleware`` owns the caching rules and
#: extends ``Vary`` on the way out.
_JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}


def json_safe_inertia_dependency(inertia_dep: Any) -> Any:
"""Wrap an Inertia dependency so its JSON branch uses ``jsonable_encoder``.

The wrapped callable keeps the ``(request, client)`` shape
``inertia_dependency_factory`` returns. An instance that doesn't expose the
private hook this relies on is handed back untouched: the failure mode is
the 500 that already happens, and refusing to boot because an upstream
attribute moved would be a worse trade.
"""

def dependency(request: Any, client: Any = None) -> Any:
inertia = inertia_dep(request, client)
if hasattr(inertia, "_get_page_data"):
inertia._render_json = _render_json_for(inertia)
else: # pragma: no cover - upstream layout changed
logger.warning(
"Inertia instance has no _get_page_data; JSON responses keep the "
"stock encoder and non-JSON-native props will fail to serialise"
)
return inertia

return dependency


def _render_json_for(inertia: Any) -> Any:
"""Build the instance's replacement ``_render_json``."""

async def _render_json() -> JSONResponse:
page_data = await inertia._get_page_data()
return JSONResponse(content=jsonable_encoder(page_data), headers=_JSON_HEADERS)

return _render_json


__all__ = ["json_safe_inertia_dependency"]
6 changes: 5 additions & 1 deletion framework/hosting/simple_module_hosting/_inertia_setup.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@
from inertia import InertiaConfig, inertia_dependency_factory
from starlette.requests import Request

from simple_module_hosting._inertia_json import json_safe_inertia_dependency
from simple_module_hosting.settings import Settings

logger = logging.getLogger(__name__)
Expand DownExpand Up@@ -184,6 +185,9 @@ def setup_inertia(
use_flash_errors=True,
)

inertia_dep = inertia_dependency_factory(inertia_config)
# Upstream's JSON branch builds a Starlette JSONResponse directly, so the
# encoder configured above only ever applies to full page loads. Wrap the
# dependency so a client-side visit encodes the same props the same way.
inertia_dep = json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
app.state.inertia_dependency = inertia_dep
return inertia_config
8 changes: 7 additions & 1 deletion framework/hosting/simple_module_hosting/_phase_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@
unhandled_exception_handler,
)
from simple_module_hosting._host_services import _HostServices
from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
from simple_module_hosting.host_settings import HostSettings
from simple_module_hosting.i18n_middleware import LocaleMiddleware
from simple_module_hosting.middleware import (
Expand DownExpand Up@@ -103,12 +104,17 @@ def install_middleware(
Order matters: last added = first executed. Execution order:
(ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
→ Inertia → CommitBeforeResponse.
→ Inertia → InertiaCache → CommitBeforeResponse.
"""
# Added first, so it is innermost and its send-wrapper is the first to see
# the response: the request's DB work commits before any byte reaches the
# client, instead of in get_db's post-response exit code (GH #257).
app.add_middleware(CommitBeforeResponseMiddleware)
# Paired with InertiaLayoutDataMiddleware below, which is what puts this
# user's auth, permissions and menus into every Inertia payload: this one
# makes sure the payload that results is never stored where a page request
# can be answered with it.
app.add_middleware(InertiaCacheMiddleware)
app.add_middleware(
InertiaLayoutDataMiddleware,
menu_registry=menu_registry,
Expand Down
Loading