diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py index 0c97ef1e..a49e88d5 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py @@ -1,4 +1,7 @@ import inspect +import asyncio +import json +import urllib.request from typing import Any, Dict, Optional, Union from urllib.parse import urlparse @@ -19,6 +22,8 @@ def __init__(self): self.root_view: str = "index.html" self.shared_props: dict = {} self.version = None + self.ssr_url: Optional[str] = None + self.ssr_timeout: float = 1.0 def set_root_view(self, view: str): self.root_view = view @@ -36,6 +41,11 @@ def get_version(self) -> Optional[str]: v = self.version() if callable(self.version) else self.version return str(v) if v is not None else None + def set_ssr(self, url: Optional[str], timeout: float = 1.0): + """Configure the Inertia SSR server endpoint. Pass None to disable SSR.""" + self.ssr_url = url.rstrip("/") if url else None + self.ssr_timeout = timeout + def render(self, component: str, props: dict) -> "InertiaResponse": return InertiaResponse( component=component, @@ -43,6 +53,8 @@ def render(self, component: str, props: dict) -> "InertiaResponse": props=props, root_view=self.root_view, version=self.get_version() or "", + ssr_url=self.ssr_url, + ssr_timeout=self.ssr_timeout, ) @@ -54,6 +66,8 @@ def __init__( props: dict, root_view: str = "index.html", version: str = "", + ssr_url: Optional[str] = None, + ssr_timeout: float = 1.0, ): # Do not call supper().__init__() — body is built lazily in __call__ self.background = None # required by FastAPI's response handling @@ -62,6 +76,8 @@ def __init__( self.props = props self.root_view = root_view self.version = version + self.ssr_url = ssr_url + self.ssr_timeout = ssr_timeout def with_(self, key: Union[str, Dict[str, Any]], value: Any = None) -> "InertiaResponse": if isinstance(key, dict): @@ -114,6 +130,13 @@ async def to_response(self, request: Request): "version": self.version, } + # SSR is used only for the initial HTML response. Inertia XHR requests + # continue returning the regular page JSON and never contact Node. + if not request.headers.get(Header.INERTIA) and self.ssr_url: + rendered = await self._render_ssr(page) + if rendered: + page["ssr"] = rendered + if request.headers.get(Header.INERTIA): return JSONResponse( content=page, @@ -135,6 +158,33 @@ async def to_response(self, request: Request): ) ) + async def _render_ssr(self, page: dict) -> Optional[dict]: + """Ask the configured Inertia Node server to render this page.""" + ssr_url = self.ssr_url + if ssr_url is None: + return None + + def request_ssr(): + body = json.dumps(page).encode("utf-8") + req = urllib.request.Request( + ssr_url.rstrip("/") + "/render", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.ssr_timeout) as response: + result = json.loads(response.read().decode("utf-8")) + if not isinstance(result, dict) or not isinstance(result.get("body"), str): + raise ValueError("Invalid Inertia SSR response") + return {"body": result["body"], "head": result.get("head", [])} + + try: + return await asyncio.to_thread(request_ssr) + except Exception: + # SSR is an enhancement: if its process is unavailable, send the + # normal page shell so the client can still hydrate it. + return None + def _get_url(self, request: Request) -> str: parsed = urlparse(str(request.url)) url = parsed.path @@ -174,6 +224,11 @@ def share(key: Union[str, Dict[str, Any]], value: Any = None): def version(version): Inertia.instance().set_version(version) + @staticmethod + def ssr(url: Optional[str] = "http://127.0.0.1:13714", timeout: float = 1.0): + """Enable SSR through the standard Inertia SSR server endpoint.""" + Inertia.instance().set_ssr(url, timeout) + @staticmethod def get_version() -> Optional[str]: return Inertia.instance().get_version() diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py index 92c090a8..0894b592 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py @@ -18,10 +18,19 @@ def boot(self) -> None: templates = self.app.make("templates") def inertia_helper(page): - encoded_page = json.dumps(page) + client_page = {key: value for key, value in page.items() if key != "ssr"} + encoded_page = json.dumps(client_page).replace("<", "\\u003c") + ssr_body = page.get("ssr", {}).get("body", "") return Markup( - f'
' + f'
{ssr_body}
' ) + def inertia_head(page): + head = page.get("ssr", {}).get("head", []) + if isinstance(head, str): + head = [head] + return Markup("".join(str(item) for item in head)) + templates.env.globals["inertia"] = inertia_helper + templates.env.globals["inertia_head"] = inertia_head templates.env.globals["Inertia"] = self.app.make("inertia") diff --git a/fastapi_startkit/tests/inertia/test_inertia.py b/fastapi_startkit/tests/inertia/test_inertia.py index f6a9dced..e6c0e85d 100644 --- a/fastapi_startkit/tests/inertia/test_inertia.py +++ b/fastapi_startkit/tests/inertia/test_inertia.py @@ -38,6 +38,17 @@ def test_factory_render_returns_response(self): self.assertEqual(response.props, {"count": 10}) self.assertEqual(response.shared_props, {"auth": {"user": None}}) + def test_factory_ssr_configuration_is_forwarded_to_response(self): + factory = ResponseFactory() + factory.set_ssr("http://localhost:13714/", timeout=2.5) + + response = factory.render("Dashboard", {}) + + self.assertEqual(factory.ssr_url, "http://localhost:13714") + self.assertEqual(factory.ssr_timeout, 2.5) + self.assertEqual(response.ssr_url, "http://localhost:13714") + self.assertEqual(response.ssr_timeout, 2.5) + def test_facade_singleton(self): instance1 = Inertia.instance() instance2 = Inertia.instance() @@ -52,3 +63,9 @@ def test_facade_proxies_to_instance(self): Inertia.set_root_view("app.html") self.assertEqual(Inertia.instance().root_view, "app.html") + + def test_facade_ssr_configures_instance(self): + Inertia.ssr("http://localhost:13714/", timeout=3.0) + + self.assertEqual(Inertia.instance().ssr_url, "http://localhost:13714") + self.assertEqual(Inertia.instance().ssr_timeout, 3.0) diff --git a/fastapi_startkit/tests/inertia/test_inertia_response.py b/fastapi_startkit/tests/inertia/test_inertia_response.py index 96015e3e..27214a5f 100644 --- a/fastapi_startkit/tests/inertia/test_inertia_response.py +++ b/fastapi_startkit/tests/inertia/test_inertia_response.py @@ -1,6 +1,6 @@ import json import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from fastapi import Request from fastapi_startkit.inertia.inertia import InertiaResponse, OptionalProp from fastapi_startkit.inertia.constant import Header @@ -111,3 +111,60 @@ async def test_inertia_response_initial_render_raises_if_no_templates(self): # This should fail because we haven't mocked the application container with self.assertRaisesRegex(RuntimeError, "Inertia requires 'templates' to be bound"): await response.to_response(self.mock_request) + + async def test_ssr_posts_page_to_standard_render_endpoint(self): + response = InertiaResponse("Dashboard", {}, {}, ssr_url="http://127.0.0.1:13714/") + page = {"component": "Dashboard", "url": "/", "props": {}} + http_response = MagicMock() + http_response.__enter__.return_value.read.return_value = ( + b'{"head":["Home"],"body":"
SSR
"}' + ) + + with patch("fastapi_startkit.inertia.inertia.urllib.request.urlopen", return_value=http_response) as urlopen: + rendered = await response._render_ssr(page) + + request = urlopen.call_args.args[0] + self.assertEqual(request.full_url, "http://127.0.0.1:13714/render") + self.assertEqual(json.loads(request.data), page) + self.assertEqual(rendered, {"head": ["Home"], "body": "
SSR
"}) + + async def test_ssr_failure_falls_back_to_client_rendering(self): + response = InertiaResponse("Dashboard", {}, {}, ssr_url="http://127.0.0.1:13714") + with patch("fastapi_startkit.inertia.inertia.urllib.request.urlopen", side_effect=OSError): + self.assertIsNone(await response._render_ssr({"url": "/", "component": "Dashboard"})) + + async def test_ssr_without_url_returns_none(self): + response = InertiaResponse("Dashboard", {}, {}) + + self.assertIsNone(await response._render_ssr({"url": "/", "component": "Dashboard"})) + + async def test_ssr_rejects_response_without_string_body(self): + response = InertiaResponse("Dashboard", {}, {}, ssr_url="http://127.0.0.1:13714") + http_response = MagicMock() + http_response.__enter__.return_value.read.return_value = b'{"body": null}' + + with patch("fastapi_startkit.inertia.inertia.urllib.request.urlopen", return_value=http_response): + self.assertIsNone(await response._render_ssr({"url": "/", "component": "Dashboard"})) + + async def test_initial_response_includes_successful_ssr_render(self): + response = InertiaResponse("Dashboard", {}, {}, ssr_url="http://127.0.0.1:13714") + http_response = MagicMock() + http_response.__enter__.return_value.read.return_value = ( + b'{"head":["Home"],"body":"
SSR
"}' + ) + templates = MagicMock() + expected_response = MagicMock() + templates.TemplateResponse.return_value = expected_response + + with ( + patch("fastapi_startkit.inertia.inertia.urllib.request.urlopen", return_value=http_response), + patch("fastapi_startkit.application.app") as container, + ): + container.return_value.has.return_value = True + container.return_value.make.return_value = templates + + actual_response = await response.to_response(self.mock_request) + + self.assertIs(actual_response, expected_response) + page = templates.TemplateResponse.call_args.args[2]["page"] + self.assertEqual(page["ssr"], {"head": ["Home"], "body": "
SSR
"}) diff --git a/fastapi_startkit/tests/inertia/test_provider.py b/fastapi_startkit/tests/inertia/test_provider.py index aa1cb92a..8d9240d3 100644 --- a/fastapi_startkit/tests/inertia/test_provider.py +++ b/fastapi_startkit/tests/inertia/test_provider.py @@ -56,3 +56,50 @@ def test_inertia_helper_renders_page_markup(self): assert json.dumps(page) in html assert 'data-page="app"' in html assert 'id="app"' in html + + def test_inertia_helper_renders_ssr_body(self): + templates = make_templates() + app = MagicMock() + app.has.return_value = True + app.make.side_effect = lambda key: {"templates": templates, "inertia": "INERTIA"}[key] + + InertiaProvider(app).boot() + helper = templates.env.globals["inertia"] + + html = str(helper({"component": "Dashboard", "props": {}, "ssr": {"body": "
SSR
"}})) + + assert '
SSR
' in html + + def test_inertia_head_renders_ssr_head_entries(self): + templates = make_templates() + app = MagicMock() + app.has.return_value = True + app.make.side_effect = lambda key: {"templates": templates, "inertia": "INERTIA"}[key] + + InertiaProvider(app).boot() + helper = templates.env.globals["inertia_head"] + + head = helper({"ssr": {"head": ["Dashboard", '']}}) + + assert str(head) == 'Dashboard' + assert str(helper({"ssr": {"head": "Single entry"}})) == "Single entry" + + def test_inertia_helper_omits_ssr_data_from_client_page_json(self): + templates = make_templates() + app = MagicMock() + app.has.return_value = True + app.make.side_effect = lambda key: {"templates": templates, "inertia": "INERTIA"}[key] + + InertiaProvider(app).boot() + helper = templates.env.globals["inertia"] + + page = { + "component": "Dashboard", + "props": {"count": 3, "content": "", 1)[0] + + assert json.loads(client_json) == {"component": "Dashboard", "props": {"count": 3, "content": "