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
55 changes: 55 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/inertia/inertia.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -36,13 +41,20 @@ 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,
shared_props=self.shared_props,
props=props,
root_view=self.root_view,
version=self.get_version() or "",
ssr_url=self.ssr_url,
ssr_timeout=self.ssr_timeout,
)


Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 11 additions & 2 deletions fastapi_startkit/src/fastapi_startkit/inertia/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<script data-page="app" type="application/json">{encoded_page}</script><div id="app"></div>'
f'<script data-page="app" type="application/json">{encoded_page}</script><div id="app">{ssr_body}</div>'
)

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")
17 changes: 17 additions & 0 deletions fastapi_startkit/tests/inertia/test_inertia.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
59 changes: 58 additions & 1 deletion fastapi_startkit/tests/inertia/test_inertia_response.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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":["<title>Home</title>"],"body":"<main>SSR</main>"}'
)

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": ["<title>Home</title>"], "body": "<main>SSR</main>"})

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":["<title>Home</title>"],"body":"<main>SSR</main>"}'
)
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": ["<title>Home</title>"], "body": "<main>SSR</main>"})
47 changes: 47 additions & 0 deletions fastapi_startkit/tests/inertia/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<main>SSR</main>"}}))

assert '<div id="app"><main>SSR</main></div>' 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": ["<title>Dashboard</title>", '<meta name="description" content="Home">']}})

assert str(head) == '<title>Dashboard</title><meta name="description" content="Home">'
assert str(helper({"ssr": {"head": "<title>Single entry</title>"}})) == "<title>Single entry</title>"

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": "<script>"},
"ssr": {"head": ["<title>Dashboard</title>"], "body": "<main>SSR</main>"},
}
html = str(helper(page))
client_json = html.split(">", 1)[1].split("</script>", 1)[0]

assert json.loads(client_json) == {"component": "Dashboard", "props": {"count": 3, "content": "<script>"}}
assert "\\u003cscript>" in client_json
2 changes: 2 additions & 0 deletions packages/fastapi-vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ interface PluginConfig {
publicDirectory?: string,
buildDirectory?: string,
hotFile?: string,
/** Entry point passed to `vite build --ssr`. Defaults to the client input. */
ssr?: Rolldown.InputOption,
/** Output directory for the SSR bundle, relative to the project root. */
ssrOutputDirectory?: string,
refresh?: boolean | string | string[] | RefreshConfig | RefreshConfig[],
transformOnServe?: (code: string, url: DevServerUrl) => string,
Expand Down
Loading