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": "