diff --git a/docs/changelog.md b/docs/changelog.md index b7b78a1..9e9b1d3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,6 +24,7 @@ Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include - **Converted Markdown keeps agents on the Markdown channel** (breaking behavior change). `autoConvert` output — and the public `html_to_markdown()` helper — now rewrites internal page links to their `.md` renditions: `[About](/about)` becomes `[About](/about.md)`, preserving query strings and fragments (`/about?x=1#y` → `/about.md?x=1#y`). External URLs, `mailto:`/`tel:` links, `/api/` routes, asset paths with a file extension, and links already ending in `.md` are untouched, and `wrap_markdown` hooks receive the rewritten Markdown. **Breaking:** `html_to_markdown()` rewrites by default — pass `rewrite_links=False` for the old verbatim behavior. See [AI accessibility → autoConvert](guides/llms.md#autoconvert-the-lossy-fallback). - **Fix: a burst of rapid file saves can no longer kill the dev server.** Two dev-server robustness fixes land together. First, build passes are now serialized and `meta.json` is written atomically and only when its content changed — previously a debounce-timer race could run two rebuilds at once, and one pass reading the build metadata mid-write misdiagnosed it as a schema mismatch and wiped the whole `.pyxle-build` cache, deleting and recreating `vite.config.js`. Vite answers a config change with a full restart, and a restart racing the next rebuild could die (`[vite] process exited`), leaving nothing listening until a manual restart. Second, the Vite subprocess is now properly supervised: **any** exit the dev server didn't initiate — including a "clean" exit code 0 — logs a clear warning and relaunches Vite with a short exponential backoff (0.5s, doubling) and a bounded budget (3 consecutive attempts). If relaunching keeps failing, a structured `ViteSupervisionError` is surfaced loudly in the terminal instead of a silently dead asset server. - **Fix: touching `request.state.` without the providing plugin now explains itself.** A loader or `@action` that reads a `request.state` attribute nothing populated (most commonly `request.state.db` without the pyxle-db plugin) used to 500 with a bare `AttributeError: 'State' object has no attribute 'db'` and a raw traceback that pointed nowhere. The framework now detects exactly this case and reports it as a structured error with guidance: state attributes are provided by plugins or middleware — e.g. `request.state.db` requires the pyxle-db plugin in `pyxle.config.json` (`"plugins": ["pyxle-db"]`). In dev the message appears in the error overlay, the action's JSON error, and the server log (with the original traceback chained); in production the response stays generic, as always. Any other `AttributeError` from your code flows through unchanged. +- **Actionable SSR error when a component touches a browser global.** A component that evaluates `window`, `document`, `navigator`, `localStorage`, `sessionStorage`, `location`, `matchMedia`, or `requestAnimationFrame` at render scope used to fail SSR with a bare `window is not defined` and a traceback of framework frames — naming the route but never your file, with no hint at the fix. Dev mode (error page, overlay, log) now explains that browser globals don't exist during server-side rendering, names the page's `.pyxl` source file, and gives the remedy: move the browser-only code into a `useEffect` hook or an event handler, or render the subtree with `` — see the [Client Components guide](guides/client-components.md). Production responses stay generic (no file paths or internals in the body); the enriched explanation goes to the server log. `ReferenceError`s on other names, and all other render errors, flow through exactly as before. ## 0.6.1 — 2026-07-01 diff --git a/pyxle/ssr/__init__.py b/pyxle/ssr/__init__.py index 36b187c..fd1fafb 100644 --- a/pyxle/ssr/__init__.py +++ b/pyxle/ssr/__init__.py @@ -6,16 +6,26 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover - import-time helpers - from .renderer import ComponentRenderer, ComponentRenderError, pool_render_factory + from .renderer import ( + BROWSER_ONLY_GLOBALS, + BrowserGlobalRenderError, + ComponentRenderer, + ComponentRenderError, + detect_browser_only_global, + pool_render_factory, + ) from .template import render_document, render_error_document from .view import build_page_response from .worker_pool import SsrWorkerPool, WorkerPoolError __all__ = [ + "BROWSER_ONLY_GLOBALS", + "BrowserGlobalRenderError", "ComponentRenderError", "ComponentRenderer", "InlineStyleFragment", "RenderResult", + "detect_browser_only_global", "pool_render_factory", "SsrWorkerPool", "WorkerPoolError", @@ -29,10 +39,13 @@ def __getattr__(name: str) -> Any: # pragma: no cover - module-level indirection if name in { + "BROWSER_ONLY_GLOBALS", + "BrowserGlobalRenderError", "ComponentRenderer", "ComponentRenderError", "InlineStyleFragment", "RenderResult", + "detect_browser_only_global", "pool_render_factory", }: module = import_module(".renderer", __name__) diff --git a/pyxle/ssr/renderer.py b/pyxle/ssr/renderer.py index b1edb38..1f292cf 100644 --- a/pyxle/ssr/renderer.py +++ b/pyxle/ssr/renderer.py @@ -5,6 +5,7 @@ import asyncio import inspect import json +import re import shutil import subprocess from dataclasses import dataclass @@ -16,6 +17,82 @@ class ComponentRenderError(RuntimeError): """Raised when a component cannot be rendered server-side.""" +#: Browser-only globals that do not exist in the Node SSR environment. A +#: ``ReferenceError`` on one of these names during a server render means the +#: component evaluated a browser API at render scope — the exact mistake +#: :class:`BrowserGlobalRenderError` explains and points at the fix for. +BROWSER_ONLY_GLOBALS: tuple[str, ...] = ( + "window", + "document", + "navigator", + "localStorage", + "sessionStorage", + "location", + "matchMedia", + "requestAnimationFrame", +) + +#: Matches Node's ``ReferenceError`` message shape — ``" is not +#: defined"``, optionally prefixed with ``"ReferenceError:"`` — and captures +#: the offending identifier. +_REFERENCE_ERROR_RE = re.compile( + r"(?:\bReferenceError:\s*)?\b([A-Za-z_$][A-Za-z0-9_$]*) is not defined\b" +) + + +def detect_browser_only_global(message: str) -> str | None: + """Return the browser-only global named in a Node ``ReferenceError`` message. + + Component render bodies also run in Node during SSR, where browser APIs do + not exist — evaluating e.g. ``window.location.pathname`` at render scope + fails with ``ReferenceError: window is not defined``. This helper + recognizes that message shape and returns the offending name when it is + one of :data:`BROWSER_ONLY_GLOBALS`. Any other message — including a + ``ReferenceError`` on an unrelated identifier — returns ``None`` so those + errors flow through unchanged. + """ + for match in _REFERENCE_ERROR_RE.finditer(message): + name = match.group(1) + if name in BROWSER_ONLY_GLOBALS: + return name + return None + + +class BrowserGlobalRenderError(ComponentRenderError): + """A render failed because a browser-only global was evaluated during SSR. + + Raised in place of a bare :class:`ComponentRenderError` when the Node + render error is a ``ReferenceError`` on one of + :data:`BROWSER_ONLY_GLOBALS`. The message explains why the global is + missing on the server, names the page's ``.pyxl`` source file, and points + at the remedy. It is surfaced in development only (error overlay, dev + error page, server log); production HTTP responses stay generic and the + rich message reaches the server log alone. + """ + + def __init__(self, *, global_name: str, source_file: str, original_message: str) -> None: + """Build the enriched message from the failing global and source file. + + ``global_name`` is the browser global the component evaluated, + ``source_file`` identifies the page's ``.pyxl`` source (e.g. + ``pages/dashboard.pyxl``), and ``original_message`` is the Node + runtime's raw error text, preserved verbatim at the front of the + enriched message. + """ + self.global_name = global_name + self.source_file = source_file + self.original_message = original_message + super().__init__( + f"{original_message}. '{global_name}' is a browser global, and browser " + f"globals do not exist during server-side rendering: the component in " + f"{source_file} runs in Node on the server first, where there is no " + f"'{global_name}'. Move the browser-only code into a useEffect hook or " + "an event handler (neither runs during the server render), or render " + "the subtree client-only with . See the Client Components " + "guide (docs/guides/client-components.md)." + ) + + @dataclass(frozen=True) class InlineStyleFragment: """Inline CSS artifact emitted by the SSR runtime.""" @@ -420,9 +497,12 @@ async def _render( __all__ = [ + "BROWSER_ONLY_GLOBALS", + "BrowserGlobalRenderError", "ComponentRenderError", "ComponentRenderer", "InlineStyleFragment", "RenderResult", + "detect_browser_only_global", "pool_render_factory", ] diff --git a/pyxle/ssr/view.py b/pyxle/ssr/view.py index 944ce21..1c84228 100644 --- a/pyxle/ssr/view.py +++ b/pyxle/ssr/view.py @@ -23,7 +23,13 @@ from pyxle.devserver.routes import PageRoute from pyxle.devserver.settings import DevServerSettings -from .renderer import ComponentRenderer, ComponentRenderError, InlineStyleFragment +from .renderer import ( + BrowserGlobalRenderError, + ComponentRenderer, + ComponentRenderError, + InlineStyleFragment, + detect_browser_only_global, +) from .template import ( _AUTH_SEED_ABSENT, ManifestLookupError, @@ -160,6 +166,39 @@ def _log_render_failure( ) +def _enrich_render_error(exc: ComponentRenderError, page: PageRoute) -> ComponentRenderError: + """Upgrade a browser-global ``ReferenceError`` into an actionable error. + + A component that evaluates a browser global (``window``, ``document``, …) + at render scope fails SSR with a bare ``"window is not defined"`` whose + traceback is entirely framework frames — it names no user file and hints + at no fix. When the render error matches that shape, return a + :class:`~pyxle.ssr.renderer.BrowserGlobalRenderError` that explains the + server/browser split, names the page's ``.pyxl`` source file, and points + at the remedy. Any other render error is returned unchanged so unrelated + failures flow through exactly as before. + + The enriched message is shown in development (error overlay, dev error + page) and written to the server log; production responses are already + sanitized to a generic document, so no file path or internals reach the + HTTP body (CLAUDE.md rule 18). + """ + if isinstance(exc, BrowserGlobalRenderError): + return exc + global_name = detect_browser_only_global(str(exc)) + if global_name is None: + return exc + enriched = BrowserGlobalRenderError( + global_name=global_name, + source_file=f"pages/{page.source_relative_path.as_posix()}", + original_message=str(exc), + ) + # Preserve the original exception as the cause so log tracebacks keep the + # raw Node runtime failure alongside the enriched explanation. + enriched.__cause__ = exc + return enriched + + def _error_response( *, settings: DevServerSettings, @@ -385,6 +424,7 @@ async def _handle_render_exception( elif isinstance(exc, HeadEvaluationError): stage, status_code = "server", 500 elif isinstance(exc, ComponentRenderError): + exc = _enrich_render_error(exc, page) stage, status_code = "renderer", 500 else: # pragma: no cover - defensive guardrail for unexpected faults if overlay is not None: @@ -693,7 +733,7 @@ async def build_page_navigation_response( overlay=overlay, loader_breadcrumb=loader_breadcrumb, stage="renderer", - error=exc, + error=_enrich_render_error(exc, page), ) except Exception as exc: # pragma: no cover - defensive guardrail return await _navigation_error_response( diff --git a/tests/ssr/test_renderer.py b/tests/ssr/test_renderer.py index 09316c7..d6b07a5 100644 --- a/tests/ssr/test_renderer.py +++ b/tests/ssr/test_renderer.py @@ -10,12 +10,15 @@ import pyxle.ssr.renderer as renderer_module from pyxle.ssr.renderer import ( + BROWSER_ONLY_GLOBALS, + BrowserGlobalRenderError, ComponentRenderer, ComponentRenderError, RenderResult, _derive_project_paths, _format_node_error, _parse_runtime_output, + detect_browser_only_global, ) from tests.ssr.utils import ensure_test_node_modules @@ -535,3 +538,65 @@ class DummyProcess: with pytest.raises(ComponentRenderError): runtime.render({}) + + +# --------------------------------------------------------------------------- +# Browser-global ReferenceError detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", BROWSER_ONLY_GLOBALS) +def test_detect_browser_only_global_matches_each_global(name: str) -> None: + """Every browser-only global's bare ReferenceError message is detected.""" + assert detect_browser_only_global(f"{name} is not defined") == name + + +def test_detect_browser_only_global_accepts_reference_error_prefix() -> None: + """The full Node message shape with the ReferenceError prefix is detected.""" + assert detect_browser_only_global("ReferenceError: window is not defined") == "window" + + +def test_detect_browser_only_global_matches_inside_larger_message() -> None: + """A ReferenceError embedded in surrounding runtime text is still found.""" + message = "Render failed: localStorage is not defined\n at Page (pages/index.jsx:3)" + assert detect_browser_only_global(message) == "localStorage" + + +@pytest.mark.parametrize( + "message", + [ + "myHelper is not defined", + "ReferenceError: fetchData is not defined", + "SSR runtime failed to execute", + "window.matchMedia is not a function", + "thewindow is not defined", + "", + ], +) +def test_detect_browser_only_global_ignores_unrelated_errors(message: str) -> None: + """Unrelated errors — including ReferenceErrors on other names — return None.""" + assert detect_browser_only_global(message) is None + + +def test_browser_global_render_error_message_and_attributes() -> None: + """The enriched error names the source file, the global, and the remedy.""" + error = BrowserGlobalRenderError( + global_name="window", + source_file="pages/dashboard.pyxl", + original_message="window is not defined", + ) + + assert isinstance(error, ComponentRenderError) + assert error.global_name == "window" + assert error.source_file == "pages/dashboard.pyxl" + assert error.original_message == "window is not defined" + + message = str(error) + assert message.startswith("window is not defined") + assert "pages/dashboard.pyxl" in message + assert "browser global" in message + assert "server-side rendering" in message + assert "useEffect" in message + assert "event handler" in message + assert "" in message + assert "docs/guides/client-components.md" in message diff --git a/tests/ssr/test_view.py b/tests/ssr/test_view.py index 12189cd..dafa0b8 100644 --- a/tests/ssr/test_view.py +++ b/tests/ssr/test_view.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import sys from dataclasses import replace from pathlib import Path @@ -12,7 +13,12 @@ from pyxle.devserver.routes import PageRoute from pyxle.devserver.settings import DevServerSettings from pyxle.ssr import view as ssr_view -from pyxle.ssr.renderer import ComponentRenderError, InlineStyleFragment, RenderResult +from pyxle.ssr.renderer import ( + BrowserGlobalRenderError, + ComponentRenderError, + InlineStyleFragment, + RenderResult, +) from pyxle.ssr.view import ( HeadEvaluationError, build_page_navigation_response, @@ -2834,3 +2840,226 @@ async def test_build_page_response_missing_state_stays_generic_in_production( assert "pyxle-db" not in body assert "request.state" not in body assert "MissingRequestStateError" not in body + + +# --------------------------------------------------------------------------- +# Browser-global render errors — enrichment, prod hygiene, regression +# --------------------------------------------------------------------------- + + +def _failing_renderer(message: str) -> StubRenderer: + """A renderer whose render always fails with *message*.""" + + class _Failing(StubRenderer): + async def render( + self, + component_path: Path, + props: dict[str, object], + *, + request_pathname: str | None = None, + csrf_token: str | None = None, + ) -> RenderResult: # type: ignore[override] + raise ComponentRenderError(message) + + return _Failing() + + +def _plain_request() -> Request: + return Request( + {"type": "http", "http_version": "1.1", "method": "GET", "path": "/", "root_path": "", "headers": []} + ) + + +def test_enrich_render_error_upgrades_browser_global(tmp_path: Path) -> None: + """A browser-global ReferenceError becomes a BrowserGlobalRenderError.""" + page = _page_route(tmp_path, loader_name=None) + original = ComponentRenderError("document is not defined") + + enriched = ssr_view._enrich_render_error(original, page) + + assert isinstance(enriched, BrowserGlobalRenderError) + assert enriched.global_name == "document" + assert enriched.source_file == "pages/index.pyxl" + assert enriched.original_message == "document is not defined" + assert enriched.__cause__ is original + # Already-enriched errors pass through untouched (no double wrapping). + assert ssr_view._enrich_render_error(enriched, page) is enriched + + +def test_enrich_render_error_leaves_unrelated_errors_untouched(tmp_path: Path) -> None: + """Non-browser-global render errors flow through as the same object.""" + page = _page_route(tmp_path, loader_name=None) + for message in ("render boom", "someHelper is not defined"): + original = ComponentRenderError(message) + assert ssr_view._enrich_render_error(original, page) is original + + +@pytest.mark.anyio +async def test_build_page_response_enriches_browser_global_error_in_dev( + settings: DevServerSettings, tmp_path: Path +) -> None: + """Dev error page + overlay explain the browser-global failure and the fix.""" + page = _page_route(tmp_path, loader_name=None) + renderer = _failing_renderer("window is not defined") + overlay = StubOverlay() + + response = await build_page_response( + request=_plain_request(), + settings=settings, + page=page, + renderer=renderer, + overlay=overlay, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "window is not defined" in body + assert "pages/index.pyxl" in body + assert "browser global" in body + assert "useEffect" in body + assert "BrowserGlobalRenderError" in body + # The overlay's renderer breadcrumb carries the enriched detail too. + error_events = [event for event in overlay.events if event[0] == "error"] + assert error_events + renderer_breadcrumb = error_events[0][2][1] + assert renderer_breadcrumb["status"] == "failed" + assert "pages/index.pyxl" in renderer_breadcrumb["detail"] + assert "useEffect" in renderer_breadcrumb["detail"] + + +@pytest.mark.anyio +async def test_build_page_response_browser_global_error_is_generic_in_production( + settings: DevServerSettings, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Production body stays generic; the enriched message goes to the log only.""" + prod_settings = replace(settings, debug=False) + page = _page_route(tmp_path, loader_name=None) + renderer = _failing_renderer("window is not defined") + + with caplog.at_level(logging.ERROR, logger="pyxle.ssr.view"): + response = await build_page_response( + request=_plain_request(), + settings=prod_settings, + page=page, + renderer=renderer, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "Server Error" in body + # No internals in the HTTP body (security rule 18): no global name, no + # source file, no remedy text, no exception type. + assert "window" not in body + assert "index.pyxl" not in body + assert "pages/" not in body + assert "useEffect" not in body + assert "BrowserGlobalRenderError" not in body + # The enriched explanation is preserved server-side for the operator. + assert "pages/index.pyxl" in caplog.text + assert "browser global" in caplog.text + assert "useEffect" in caplog.text + + +@pytest.mark.anyio +async def test_build_page_response_unrelated_reference_error_not_rewritten( + settings: DevServerSettings, tmp_path: Path +) -> None: + """A ReferenceError on a non-browser name renders exactly as before.""" + page = _page_route(tmp_path, loader_name=None) + renderer = _failing_renderer("someHelper is not defined") + overlay = StubOverlay() + + response = await build_page_response( + request=_plain_request(), + settings=settings, + page=page, + renderer=renderer, + overlay=overlay, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "someHelper is not defined" in body + assert "ComponentRenderError" in body + assert "browser global" not in body + assert "useEffect" not in body + assert "BrowserGlobalRenderError" not in body + + +@pytest.mark.anyio +async def test_build_page_navigation_response_enriches_browser_global_error( + settings: DevServerSettings, tmp_path: Path +) -> None: + """The dev nav-error JSON carries the enriched message and error type.""" + page = _page_route(tmp_path, loader_name=None) + renderer = _failing_renderer("document is not defined") + + response = await build_page_navigation_response( + request=_plain_request(), + settings=settings, + page=page, + renderer=renderer, + overlay=StubOverlay(), + ) + + payload = json.loads(await _read_response_body(response)) + assert response.status_code == 500 + assert payload["ok"] is False + assert payload["stage"] == "renderer" + assert payload["errorType"] == "BrowserGlobalRenderError" + assert "pages/index.pyxl" in payload["error"] + assert "useEffect" in payload["error"] + + +@pytest.mark.anyio +async def test_build_page_navigation_response_browser_global_error_generic_in_production( + settings: DevServerSettings, tmp_path: Path +) -> None: + """The production nav-error JSON leaks neither the global nor the file.""" + prod_settings = replace(settings, debug=False) + page = _page_route(tmp_path, loader_name=None) + renderer = _failing_renderer("localStorage is not defined") + + response = await build_page_navigation_response( + request=_plain_request(), + settings=prod_settings, + page=page, + renderer=renderer, + overlay=StubOverlay(), + ) + + raw = (await _read_response_body(response)).decode() + payload = json.loads(raw) + assert response.status_code == 500 + assert payload["errorType"] == "ServerError" + assert payload["error"] == "The server encountered an error while processing this request." + assert "localStorage" not in raw + assert "pyxl" not in raw + assert "useEffect" not in raw + + +@pytest.mark.anyio +async def test_build_streaming_page_response_enriches_browser_global_shell_error( + settings: DevServerSettings, tmp_path: Path +) -> None: + """A browser-global shell error on the streaming path gets the same enrichment.""" + page = replace(_page_route(tmp_path, loader_name=None), uses_suspense=True) + renderer = StubRenderer() + overlay = StubOverlay() + + response = await build_streaming_page_response( + request=_plain_request(), + settings=settings, + page=page, + renderer=renderer, + stream_render=_stream_of({"type": "error", "error": "matchMedia is not defined"}), + overlay=overlay, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "matchMedia is not defined" in body + assert "pages/index.pyxl" in body + assert "useEffect" in body