Changelog

docs/changelog.md · line 64 of 64

Release notes for Pyxle. While we're in beta (0.x), minor versions may include breaking changes — those are called out explicitly. To upgrade, run pip install --upgrade pyxle-framework.

0.9.4#

  • assets.inlineStylesheets: inline a page's compiled CSS into the document instead of render-blocking <link> tags"never" (default) / "auto" (up to inlineStylesheetLimit bytes per sheet) / "always"; first paint stops waiting on a stylesheet round trip, an unreadable sheet degrades to its normal link, and the build-optimization guide covers the cacheability trade.

  • assets.publicMaxAge: configurable Cache-Control lifetime for un-hashed public/ files in production — the previous fixed one hour is now the default; hashed client bundles stay immutable for a year, and development keeps no-cache.

  • Module-preload hints are now fetchpriority="low", and assets.modulePreload: false can drop them entirely — hydration chunks exist to hydrate a page the server already painted, so they no longer compete with the document, CSS, or the LCP image for pre-paint bandwidth; content-first pages can opt out of the hints altogether.

  • assets.hydration: "after-paint": paint first, hydrate a frame later — the production shell injects the client entry only after the first frame has been presented, so a content-first page paints its (already complete) server HTML with zero JavaScript in flight. Default stays "eager".

  • Docs: a whole-file .pyxl example is now one pyxl code block instead of a python fence stacked on a jsx fence — one file in the docs the way it is one file on disk.

0.9.3#

  • Fix: the root vite.config.js now loads after a build too. 0.9.2 stopped a freshly scaffolded project's root config from throwing ERR_MODULE_NOT_FOUND before the first build, and it did — but it left a second failure in its place. The generated config it defers to sets const clientRoot = __dirname, and a scaffolded project's package.json declares "type": "module", so that identifier does not exist there. Nothing caught it because Vite injects __dirname when it bundles the config it is handed directly, which is exactly how pyxle dev and pyxle build load it; the root config instead reaches the generated one through a runtime dynamic import(), which no bundler rewrites. So from 0.9.2 any tool that reads the project's Vite config got __dirname is not defined in ES module scope on a project that had been built. Vitest could not start at all — the failure surfaces as a stack trace pointing inside .pyxle-build/, a directory you did not write. Measured with Vite's own loadConfigFromFile: 0.9.1 failed before a build and worked after; 0.9.2 was the exact inverse. Both states now work, so tools that read the project's Vite config — Vitest among them — start instead of crashing on it. Vitest then inherits that config's root, which is the client directory Pyxle generates, so point it at your own tests with vitest run --root .; the Testing guide explains why. (shadcn is unaffected by this bug — it does not read the Vite config at all.) clientRoot is derived from import.meta.url, and a test asserts the generated config contains no __dirname in code.

  • Fix: shadcn/ui components are styled. Tailwind now scans your own source directories. Vite's root is the generated client directory, which holds the compiled pages and nothing else, and Tailwind v4 auto-detects its sources from that root. So a utility class used only in your own components/ — which is exactly where shadcn/ui puts every component it installs — was never generated, and the component rendered unstyled, with no error anywhere: npx shadcn@latest add button reported success and the button came out with no background. Adding @source "../../components" to your stylesheet did not help either, because the stylesheet is copied into the build directory, so that path resolved inside it and silently matched nothing. Pyxle now rewrites the directives at copy time, pointing Tailwind at the directories your jsconfig.json declares (pages excluded — it is already under the Vite root). Verified in a browser on a scaffolded shadcn project: a class present only in components/ui/button.jsx now generates, in pyxle dev and in the production bundle. Plain-CSS projects are untouched — nothing is injected into a stylesheet that does not import Tailwind.

0.9.2#

  • Internal: API modules are imported with the real debug flag, not a hardcoded one. build_api_router passed debug=True unconditionally, so a production app went through the development module loader. It was inert — only the dev file watcher advances the reload generation and production runs no watcher — but the code described a dev path in a production one, and the next change to make that generation mean something in production would have inherited a silent re-import of every API module. Verified both ways by running: a dev server still picks up an edited pages/api/*.py on the next request, and a production pyxle serve still answers its API routes.

  • Fix: /llms.txt links stay https:// behind a TLS-terminating proxy. The absolute URLs were built from the socket's scheme, so an app behind a proxy that speaks plain HTTP to it published a list of http:// links on an HTTPS site — handed to the one audience that follows them literally. The previous note that "uvicorn's proxy-header support keeps these correct" was true only when the proxy shares the host: uvicorn honours X-Forwarded-Proto only from peers in forwarded_allow_ips (127.0.0.1 by default), so an nginx, load balancer or ingress on another host or container sent the header and had it ignored. Measured both ways on one server: from loopback the links came out https://, from a non-loopback peer the identical request produced http://. Pyxle now reads the header itself. The rule lives in one place and the CSRF cookie's Secure decision uses the same function, so the two surfaces cannot drift into disagreeing about whether the client used TLS. A proxy that reports http is still taken at its word — the scheme is never guessed upwards.

  • Fix: a freshly scaffolded project's root vite.config.js no longer throws. The scaffold writes one so shadcn/ui framework detection, editor integrations and other tools that expect a config at the project root can find it — and it re-exported .pyxle-build/client/vite.config.js, which does not exist until the first pyxle dev or pyxle build. So on a brand-new project the file existed and raised ERR_MODULE_NOT_FOUND: the tools it was written for found a config that crashes. It now resolves lazily, returning the generated config once there is one and an empty config before that. A failure inside the generated config — a plugin that is not installed — still propagates rather than being flattened into an empty config, which is a distinction the first version of this fix got wrong and a test now pins.

  • Fix: cookieSameSite: "none" no longer fails silently on a plain-HTTP origin. A SameSite=None cookie must also be Secure — the specification requires the pair, and every current browser rejects the cookie without it. Pyxle deliberately withholds Secure over plain HTTP (a Secure cookie is dropped there, which would break the form and protect nothing), and that reasoning was applied to SameSite=None too — where it cannot work, because the browser drops the cookie either way. Measured in a real browser: on a plain-HTTP dev server with "none", the browser kept zero cookies while the page rendered perfectly; with "lax" on the same server it kept the CSRF cookie. So the token never reached the client and every @action failed its CSRF check, with nothing in the terminal or the console to say why. The header is now spec-correct — SameSite=None; Secure always travel together, so devtools names the reason — and the server logs a warning, once, the first time it emits that pair without TLS, saying what will break and how to fix it. "lax" and "strict" are untouched. Configuration.

  • Docs fix: the pyxle routes sample output did not match what the command prints. Both the quick-start and the CLI reference reproduced it with the section headers unprefixed and hand-indented, and the CLI reference also wrote page paths as pages/index.pyxl where the command prints them relative to pages/ — contradicting the quick-start's own sentence saying so. Both samples are now the command's real output, captured by running it, and the routes test pins the shape instead of only checking that the command said anything.

  • Docs fix: the multi-method API example refused HEAD. The API-routes guide's primary shape — an endpoint function branching on request.method — tested == "GET" and fell through to its own 405 for anything else, so a route copied from our own documentation answered 405 Method Not Allowed to curl -I, uptime monitors, health probers and link checkers. The framework was not at fault and is unchanged: it deliberately lets HEAD through to your handler wherever the route accepts GET, and the class-based HTTPEndpoint alternative in the same guide got this right, which is what made the gap easy to miss. The example now handles GET and HEAD together and says why, and the note on enforce_allowed_methods states the rule. If you copied the old shape, widen that first branch to if request.method in ("GET", "HEAD"). API routes.

  • Fix: a client-side navigation no longer calls your LoaderError a server error. An author-raised LoaderError or ActionError is deliberate, user-facing copy, and it reaches the visitor verbatim in every environment — that is the point of raising it, and a full page load has always honoured it. A client-side navigation to the same page did not: its JSON payload replaced the message with a generic string and the type with ServerError, in production, for every failure. So one visitor pasted the URL and read "That post was deleted in 2019." while another clicked a link to the same page and read "The server encountered an error." — and only the second was wrong. The navigation payload is now built by the same function the HTML boundary uses, rather than a second copy of the rule that had drifted from it, so an author-raised message, its data, and its status all arrive intact. Nothing loosened for anything else: a non-author exception is still replaced with a generic string and ServerError in production, with the exception's own class name withheld. Error handling.

  • Fix: every module imports on its own again. import pyxle.ssr.view and import pyxle.ssr.template raised ImportError: ... most likely due to a circular import from a cold interpreter. pyxle/devserver/__init__.py imported starlette_app at module scope, starlette_app imports pyxle.ssr, and pyxle.ssr imports back into pyxle.devserver for dev_origins and error_pages — so entering the cycle from the SSR side found a half-built module. It never affected a running app, because import pyxle and the CLI both enter from the other side; what it broke was reading one module on its own, which is what a contributor, an editor, or a documentation tool does first. The one import that closed the loop is now made where it is used.

  • An occupied port now explains itself, before anything expensive happens. A port already in use is the most common way a dev server fails to start, and it was the least legible failure we shipped: uvicorn's own [Errno 98] error while attempting to bind ... address already in use, with no remedy and no suggestion of a port that would work. It also arrived at the wrong moment in both commands. pyxle dev had already started Vite, so the last line on screen was [vite] process exited with code 143 — the shutdown of an innocent child — and the eye lands on the last line. pyxle serve had already rebuilt the whole project, and had already printed Serving Pyxle build on http://host:port, a success line with a clickable URL, for a server that never bound. Both commands now check the port first — before Vite, before npm install, before any build — and say which port is taken, what usually holds it, a free port to use instead, and the command that names the process squatting on it. pyxle serve on a taken port now fails in well under a second instead of after a full build. The port you asked for is still never silently changed; only Vite's own port moves on its own. CLI reference.

  • Fix: the dev error document no longer calls a deliberate 404 a crash. LoaderError and ActionError take a status_code precisely so your code can decline a request — a missing post is a 404, a signed-out visitor a 401 — and the response Pyxle sent carried that status correctly. The page the developer saw did not: it was headed Server Render Failed under the title Pyxle • Error whatever the status, so the person building a deliberate 404 was told the server had broken while their visitor was told the truth — the inverse of the audience each message suits. The dev document now reads by status, the way the production one already did: a sub-500 is headed with the wording a visitor would get and says the response was one your code chose, and only a 5xx is still headed Server Render Failed. The class decides rather than a table, so a 4xx nobody has written specific wording for is still never described as a server fault. Everything a developer needs is unchanged — exception type, message, the file and line of the raise, and the Vite client tag that reloads the page when you fix it. Error handling.

0.9.1#

  • Fix: STANDALONE on a template.pyxl now stops the wrapper chain, not just the head and loader chains. STANDALONE = True makes a wrapper the root of its own chain, and three separate walks have to agree on where that root is: the loaders that run, the head contributions that land, and the markup that wraps the page. On a layout.pyxl all three stopped. On a template.pyxl only two did — the wrapper walk consulted layout.json alone and never looked at template.json, so a section behind a standalone template had its ancestors' loaders skipped and their head dropped while their markup still wrapped it. The page then rendered inside a layout whose loader had never run, so any component of that layout reading its own data found nothing — a wrapper-chain bug that presents as a data bug, in the one place the reader has no reason to look. The rule the other two walks already used is now the only rule: any wrapper, of either kind, declaring STANDALONE ends the chain above it. Layouts.

  • Fix: pyxle check no longer passes an action that can never run. An @action whose second parameter carries no type annotation — async def bump(request, payload), the signature you write without thinking — cannot be called: there is nothing to build a request model from, so the first request that triggers it fails. pyxle openapi already refused such a file and said exactly why, while pyxle check on the identical tree reported "All checks passed" and exited 0. The framework knew; the command the deployment checklist names as the gate was the one that did not ask. It asks now, with the same message openapi gives, naming the action, the parameter and the line.

    check stays static — this is read off the parsed source, not by importing your module, so there are no import-time side effects, no import errors as a new failure class, and no slower gate. Consequently it sees only what the file says: an annotated body parameter is left alone, because whether Pydantic is installed is a fact about the environment you deploy into rather than about your code, and a parameter with a default is optional and always fine. The error text now lives in pyxle.runtime beside @action itself, so the gate and the dispatcher cannot drift into describing the same mistake two ways. pyxle check.

0.9.0#

  • Docs: why the log.info that worked all through development is silent once you deploy. pyxle dev installs a logging bridge that lowers the root logger to INFO so your records reach the terminal and the browser console. pyxle serve installs nothing — the process is yours and Python's defaults apply, so INFO is dropped while WARNING and above still reach stderr. Nothing is broken and nothing warns you, which is exactly what makes it cost an afternoon. The debugging guide now says so, shows the one-line basicConfig that fixes it, and rules out the two things developers reach for first: the observability accessLog option configures the pyxle.access logger only, and a layout is not a startup hook — its module is imported lazily during a render, so configuration written there can land after the first page loader has logged. Uncaught loader and action exceptions are unaffected: they are logged with their full traceback regardless. Debugging.

  • Fix: a page that does not compile stops being reported as a page that does not exist. A .pyxl with a syntax error never compiles, so pyxle dev never registers a route for it, so requesting its URL fell through to the 404 — which advised checking that the file is in pages/ and correctly named. It was, both. The compiler error explaining why nothing served the address was already recorded and simply not shown, and a project with its own not-found.pyxl made the wrong answer look designed. In dev, a URL a broken source would have served now answers 500 with the same build-failure page a route that used to work serves: the file, the line and column, the message, and the source around it, reloading itself once the rebuild succeeds. Dynamic sources are covered too — a broken pages/posts/[slug].pyxl answers /posts/hello — matched with Starlette's own path compiler, and only where no route matched at all, so an endpoint raising HTTPException(404, "User not found") still says exactly that. A URL no broken source claims keeps the ordinary 404, and production is untouched: no failure registry is ever created there, and pyxle build still refuses to produce a dist/ from a project that does not compile. Dev server, Debugging.

  • Fix: a compile error that names a second line now names a line of your file. Some errors point at two places — where the compiler noticed the problem, and where it started. pages/products.pyxl:11:5: closing parenthesis ')' does not match opening parenthesis '[' on line 3 got the first right and the second wrong: the [ was on line 8, and 3 was its position inside the block Pyxle had extracted and handed to CPython. Line 3 of the file was fine, so the developer read correct code looking for a bug that was five lines further down — worse than an error that points nowhere, because it looks authoritative. Every checker Pyxle calls numbers its findings from the start of the half of the file it was given, and the ones that write a line number into their own prose were going out untranslated: CPython's mismatched-bracket and unterminated string literal (detected at line N) messages, and pyxle check's redefinition of unused 'os' from line N, import 'os' from line N shadowed by loop variable and local variable 'v' defined in enclosing scope on line N. All of them are now translated to .pyxl lines, the same way the position already was — in the terminal, in pyxle check, in the dev error overlay and on the build-failure page, which all read the compiler's message. A number that cannot be mapped is left alone rather than replaced with a guess — including a number that was never a line at all: __all__ = ["ghost on line 999"] is reported back to you as undefined name 'ghost on line 999' in __all__, with your own string intact. Error handling, pyxle check.

  • Fix: a failed pyxle build names the file you wrote, not the one Pyxle generated. A bundler error was printed with Rollup's own location — pages/about.jsx:2:8, a file in a build directory the author never created, at a line numbered from the start of the page's JSX half. Vite's stderr now goes through the same line map the render-time path has used since 0.8.0, so the failure reads pages/about.pyxl:13:8. A plain .jsx component you wrote yourself is copied into the build tree unchanged, so its line and column are already yours: an error in one is reported against your file, at your line, with nothing added and no build directory named. A position Pyxle genuinely cannot place is still marked (generated), so an artifact is never passed off as your own file. Only paths carrying a line number are rewritten — a bare .jsx is indistinguishable from an import specifier you typed or a line of your source quoted back in a code frame, and your own text is never edited. The known cost is that Rollup's unresolved-import error, which reports no line at all, still names a path inside the build directory — and that path cannot be converted back to one of your files reliably, because the module it names came either from the .pyxl of the same name or from a .jsx component you wrote that Pyxle copied there. The import specifier in quotes is unambiguously yours; search your own sources for it. Build and serve.

  • Hardening: a .jsx inside a URL is never rewritten. The pattern that rewrites generated positions in build and SSR error text could not see a URL scheme, so a match inside http://localhost:5176/pages/index.jsx:3:9 began after one and would have rewritten the URL's own path, eating the port. No build or render error has been observed carrying a URL of that shape, so this closes a latent corruption rather than a reported failure. A .jsx inside a URL is now left byte for byte, with or without a coordinate. The test is the scheme and the // that follow it rather than a list of hostnames, so https, file:// and Vite's /@fs/ are all covered by the same rule — and a path with no scheme is still a path, so /@fs/… with the origin stripped names a real generated module and is translated exactly as before. Build and serve.

  • Fix: pyxle serve no longer publishes the sources your bundle was built from. The client static mount was rooted one directory too high, at dist/client/ instead of dist/client/dist/. The browser only ever loads the latter — the rendered HTML references nothing outside it — so everything else in that tree was reachable and nothing ever asked for it: every page's unbundled JSX at /client/pages/*.jsx, the layout route wrappers, Pyxle's own client components, and the generated /client/vite.config.js, /client/tsconfig.json, /client/index.html and /client/manifest.json — served with a one-hour cache header, comments and all. Only Vite's output is mounted now, at /client/dist/; every other path under /client/ is a 404 like any other unknown URL. Hashed bundles keep their immutable cache header and nothing about page loading changes. pyxle build --analyze walks the same directory, so its report no longer counts build inputs (vite.config.js, client-entry.js, the JSX and CSS sources Vite consumed) that no browser downloads — expect a smaller, truthful total. Build and serve, Build optimization.

  • Fix: a page's own logging.getLogger(__name__) now reaches the browser and the terminal. log = logging.getLogger(__name__) is the first line of logging most Python developers write, and inside a .pyxl it produced complete silence in both places. A compiled page runs under a private module name that begins with pyxle., and the filter that keeps uvicorn, watchfiles and Pyxle's own logs out of your console classified the page — your code — as framework internals and dropped it. The terminal missed it for a second, independent reason: forwarding lowers the root logger to INFO, but the stderr fallback beside it stayed at WARNING, so a plain log.info(...) written any other way reached only the browser. Both are fixed: a logger belonging to your page or API module is always forwarded, and the terminal now prints whatever the browser is shown, plus the warnings and errors it always printed. A page's records are also labelled by the file that emitted them — [pyxle:server pages/about.pyxl] rather than an internal module name — while a logger you name yourself keeps that name. Genuine framework internals are still filtered out unless you pass --verbose. pyxle dev, Debugging.

  • The scaffold declares esbuild as a runtime dependency. Server rendering loads it from your project at request time, but it previously arrived only transitively through Vite — a devDependency — so a production install with --omit=dev produced a server that started cleanly and returned a 500 for every page, with the cause visible only in the log. If you generated your project with an earlier version, add esbuild to dependencies.

  • The scaffold imports its global stylesheet in pages/layout.pyxl rather than in pages/index.pyxl. A layout wraps every route, so the second page you add is styled without you having to know that. Previously it rendered unstyled, with nothing to explain why.

  • examples/charts/ ships a public/ directory, so it starts as cloned. The dev server requires one and the example did not have it.

Fixed#

  • The scaffold no longer gitignores package-lock.json. An application wants its lockfile committed — it is what makes an install reproducible and what npm ci requires — and ignoring it meant the Dockerfile in our own deployment guide could not build a freshly cloned project.

  • New example: a charting library, driven straight from a Python loader. examples/charts renders a Recharts chart from a dict a @server loader returned — one .pyxl file, npm install recharts, no API route or fetch in between. The chart's SVG is in the server-rendered HTML and the tree is live after hydration. Third-party packages gains a section on the one category that needs care: libraries that decide their layout by measuring the DOM, which does not exist during a server render. It names each thing Recharts measures — axis tick thinning, default tick text, entry animations — the mismatch each one produces and the fix, plus the ResponsiveContainer replacement that server-renders and stays responsive. Module-level counters get the same treatment: Recharts names its <clipPath> from one, so it climbs across requests inside a reused SSR worker while the browser always starts from one, and a stable id fixes it. The example ships a real public/favicon.ico: an example whose central instruction is "read the whole console" has no business putting a 404 in it.

  • Fix: the charts example hydrated with a mismatch on both <YAxis> elements — the exact failure its README is about. Recharts runs one tick pass per axis and interval picks which: a number means "take these ticks as they are", anything else — including the preserveEnd default a <YAxis> gets — means "measure the labels first", which is the path a server render can never take. React therefore named it in the console on every development load, as y="12" against y={16.796875} — the 8 and the 12.796875 Recharts computed, each plus the example's own offsetY={4}. That 12.796875 is half of 25.59375, which is not the tick's own box: Recharts sizes a label by appending a hidden <span> to document.body and measuring that, and on the render that hydrates the axis has no font size to give it yet, so it inherits the page's 16px/1.6 instead of the tick's font-size: 12. Both the guide and the example's README now say so, because a coordinate we teach people to recognise should come with the element it was measured from. Both axes now carry interval={0}, and Third-party packages says to give every axis a numeric interval, not just the horizontal one. Both that guide and the example's README now separate the two kinds of hydration mismatch, because the difference decides how you find one. An attribute mismatch like this is kept as the server rendered it — "this won't be patched up" — so the page hydrates, stays interactive and ships a value the client never agreed to, with nothing logged outside a development build. A text or structure mismatch makes React discard the server HTML for the whole root and re-render it in the browser, which production reports only as a minified error code. Neither produces a page that renders correctly and responds to nothing, and neither can be found by comparing the production DOM against the server HTML — for an attribute mismatch the two agree precisely because React kept the server's value. The guide gains a short section on the check that does work: pyxle dev with the console open, filtered for hydrat — React logs a mismatch after its own DevTools notice, so the top of the console looks the same either way. The same guide's tick-text row is corrected while we are here: Recharts' <Text> diverges by wrapping a label whose measured words overflow the axis width into one <tspan> per line — two for a two-word label — where the server emits exactly one, not by resolving em offsets. The example's README also told you to verify with pyxle build && pyxle serve, which cannot start — pyxle serve requires PYXLE_SECRET_KEY — and now shows a command that runs.

  • Fix: a production traceback names the file you wrote, not the module we generated. A loader or action that raised in production logged File "/app/dist/server/pages/x.py", line 9 where the author's raise is at pages/x.pyxl, line 7 — the wrong file and the wrong line, while printing the correct source text underneath, which made it look authoritative. That is the worst place to be wrong: production error responses are deliberately sanitised, so the log is the only record of what failed, and it sent an on-call reader to a generated artifact that may not even exist on their machine. The remapping import loader that makes tracebacks name the .pyxl already existed and was already correct — it was gated on debug, so the one environment that most needs accurate coordinates was the one environment that never got them. The gate is gone: page modules are exec'd against their .pyxl origin in production too. A dist-only deploy is unchanged, and that is what makes this safe rather than merely better — when no .pyxl is on disk there is nothing to remap to, so the import degrades to the generated module exactly as before, which is covered by its own test. Error handling and Debugging describe reading these logs.

  • The starter page now proves it hydrated. A fresh pyxle init rendered a card with the framework version, the server time and the file to edit — all of it produced by Python, and not one interactive element on the page. So the scaffold demonstrated half the pitch: data crossing from a @server loader into a component, with nothing showing that the React half was live. It also left a newcomer no way to answer the question that matters on an unfamiliar machine, because a page that renders perfectly and does nothing is exactly what a hydration failure looks like — the symptom of two bugs fixed in this very release. A starter page that cannot show that symptom cannot rule it out either. There is now a Clicked 0 times button in the card, holding client state with useState, next to a line saying what it is for: the text above it came from Python, the button is React in your browser, same file. Present in both the plain-CSS and Tailwind scaffolds. Verified by clicking it in a real browser — dev and a production pyxle build + pyxle serve — three clicks, Clicked 0 times -> Clicked 3 times, with the Python-supplied version still on the page and no hydration message in the console. Quick start says what the button is for rather than leaving it as decoration.

  • Fix: a server render that fails now names the line in your file, not just the exception. A loader crash gave the browser Loader 'load' for //x raised NameError: name 'total' is not defined and then told you to check the server terminal — where the traceback had been naming your file and line all along. The gap was worst for the failure that needs it most: an unsupported relative import (from ._shared import GREETING) rendered as "Pyxle encountered a ModuleNotFoundError. No module named pyxle.server", naming a framework-internal namespace the developer never typed. The obvious reading of that page is that Pyxle is mis-installed, and the actual cause — one import on line 1 of your own page — appeared nowhere on it. The overlay now prints the deepest frame in code you wrote, as pages/relimp.pyxl, line 1, with that line's source beneath it. Frames inside Pyxle and inside .pyxle-build/ are skipped, because an artifact path sends you to edit a generated module; __cause__/__context__ are followed, because the exception that reaches the renderer is usually a wrapper whose own traceback stops at the framework call site while the frame naming the mistake hangs off the exception it wrapped. Production is unchanged and shows none of it — no path, no line, no source — since the overlay is a development affordance and a visitor must never be shown the author's source tree. This also makes two documents true that already promised it: Debugging and Error handling both said the browser overlay carries the file and line, which held for a build failure and not for a render failure. The two paths now agree.

  • New docs page: Example applications, and it is in the site navigation. The two runnable apps under examples/ were reachable from the repository's own docs/README.md — a file the website does not build — and otherwise only by landing on the changelog, the comparison guide, or a subsection of another guide. So on pyxle.dev the examples had no entry in the navigation at all: the charts app exists to answer the question a newcomer is most likely to ask (does a real npm package actually work here?) and nothing in the docs sidebar led to it. There is now an Examples section between Core Concepts and Guides. The page says what each example is meant to prove rather than only what it contains — for charts, that Recharts is imported the way its own documentation says and nothing wraps or reimplements it, together with the honest constraint that a DOM-measuring library cannot measure a DOM that does not exist during a server render, pointing at Third-party packages for the detail; for chat, that one route file serves both the page and the WebSocket that updates it. Verified by clicking, not by URL: from Quick Start in a real browser, the sidebar link lands on the page.

  • Fix: the charts documentation quotes React's hydration errors correctly — and there are three of them, not two. examples/charts and Third-party packages each said a hydration failure is one of two messages. There are three, and the one both documents omitted is the kind their own entry-animation row produces. Each was captured verbatim from a browser console on a deliberately broken render rather than quoted from memory: an attribute that differs gives A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.; a string that differs gives Hydration failed because the server rendered text didn't match the client.; and an element one side emits and the other does not gives Hydration failed because the server rendered HTML didn't match the client.text and HTML are the only words separating the last two. Folding the third into the second meant a reader who hit the commonest Recharts mismatch would have gone looking for a sentence React never wrote, in the one document whose whole subject is recognising these messages. Both now list all three and name which one an entry animation produces. The hydrat console filter both documents recommend was always correct and still matches all three. Checked while we were in there, by the same method: the example itself logs no hydration message at all — the interval={0} fix on both <YAxis> holds.

  • Fix: deleting a page no longer breaks the next build. Remove a .pyxl you no longer want, run pyxle build, and the build failed: Could not resolve "../pages/about.jsx" from .pyxle-build/client/routes/about.jsx``. Two paths inside a cache directory you never created, naming neither the page you deleted nor anything you could act on — and the only cure was deleting .pyxle-build/ by hand, which nothing told you. A page compiles to four artifacts, and removal cleaned up three: the route entry module was left behind, still importing the page module that had correctly gone, and Vite reads that whole directory. All four are now removed together, nested pages included. Build and serve.

  • Fix: a component that throws during a server render now says which component. A runtime failure in the JSX half — a typo'd identifier, a null dereference — produced NoSuchThing is not defined and nothing else: no file, no component, no line. The document then advised checking the server logs "for full details", where the log held less than the page did, because the Node worker sent only error.message across to Python and dropped the stack. The stack now travels with the failure, and the message names the component React was executing: NoSuchThing is not defined (raised while rendering <ProductCard>). On a page with several components that is the difference between reading one of them and bisecting the file. The full stack, with generated paths translated back to the files you wrote, is logged at debug level — so the sentence about the terminal is now true, and it no longer claims details it did not have. A line number is still not reported for this class of error: the server bundle is a content-hashed module built without a source map, so its positions cannot be translated, and a number that cannot be trusted is worse than none. Build-time errors, which do carry translatable positions, are unaffected and still name your .pyxl and line. SSR pipeline.

  • Fix: an unclosed bracket names the bracket, not your indentation. A (, [ or { left open inside a function body was reported as unexpected indent on the line below it — a sentence about whitespace, when the whitespace was fine. The cause is where a .pyxl file is cut in two: the split walker takes the largest run of lines that parses as Python, and an unclosed bracket makes that run end on the line before the bracket's, so everything after it is handed to the JSX side. Parsed on its own, that leftover fragment does start with an unexpected indent — the message described the cut rather than the mistake, and sent the reader to stare at leading spaces. The fragment is now re-parsed together with the Python it was cut from, which is the code the author actually wrote, and CPython's own '(' was never closed is reported against the line holding the bracket. Only this one class of message is overridden: any other syntax error is genuinely about the lines it names and is passed through untouched, as is an unclosed bracket at top level, which was always reported correctly. Compiler.

  • Fix: an action that asks for a body parameter it never described now says so, instead of blaming Pydantic. async def act(request, payload) — a second parameter with no type annotation — failed in both directions and told you the wrong thing in each. Without Pydantic installed, which is a fresh project's normal state, it raised "This action validates its request body with a Pydantic model, but Pydantic is not installed": untrue, since the action declares no model. Install Pydantic as instructed and the same call then failed with TypeError: act() missing 1 required positional argument: 'payload' — the cryptic error the install hint existed to prevent. So the advice cost a dependency and changed nothing. The cause was order of operations: whether Pydantic is needed at all is a property of the annotation, and the annotation was read after the availability check. It is now read first, so the two cases separate: an unannotated required parameter reports that there is nothing to build a request model from and names the two ways out — annotate it with a model, or take only request and read the body with await request.json() — while the Pydantic hint is raised only when the parameter really is annotated and Pydantic really is missing. An unannotated parameter with a default is unchanged: nothing has to be injected, so the action runs. Server actions.

  • Fix: pyxle dev --host 0.0.0.0 serves a page that actually hydrates off this machine. pyxle dev runs two servers — Pyxle serves the document, Vite serves the JavaScript modules it loads — and same host on a different port is a different origin, so every <script type="module"> on the page is a cross-origin request. Vite 6.0.9 narrowed its default allow-list to loopback (defaultAllowedOrigins), and Pyxle's generated vite.config.js set host, port, origin and fs but never cors or allowedHosts. So opening the Network: URL the startup banner prints — from a phone, a second laptop, WSL2, a devcontainer, Codespaces, a VM — rendered the page perfectly and then nothing: no hydration, no interactivity, no console error, no line in the server log, because Vite refuses by withholding a response header rather than by failing. There was no way to override it either: --vite-host 0.0.0.0 still emitted localhost, the config's vite block accepted only host and port, and the project's own root vite.config.js is not the file Vite reads. The generated config now carries the origins the dev server itself answers on — loopback, and, when it is bound to every interface, private-network addresses on its own two ports. Deliberately not cors: true: a dev server that answers every origin lets any page the developer happens to have open read the source of the project they are working on. A public site holds none of these addresses and still gets no header. Dev server.

  • Fix: a second pyxle command no longer reconfigures a running dev server. Every command that builds — pyxle routes, pyxle check, pyxle build — regenerates .pyxle-build/client/vite.config.js, and only pyxle dev was ever told which addresses the server is on. So running one of them beside a pyxle dev --host 0.0.0.0 rewrote that config from the config file's loopback defaults; Vite watches its own config, restarted onto it, and every browser that was not on this machine lost the ability to load a single module. What those browsers got afterwards was the worst possible failure: a complete, correctly rendered page that never becomes interactive, with no console error, no overlay and no line in the server log — because Vite answered 200 and a refused module is not a JavaScript error. pyxle dev now records its addresses in .pyxle-build/dev-server.json, and any command that regenerates the client config while that server is alive keeps them, rewriting everything else (aliases, plugins, env defines) as before. A record left behind by a crashed server is ignored. Dev server.

  • Fix: hot reload and the error overlay now reach the browsers the dev server invited. The dev WebSocket at /__pyxle__/overlay refused every origin that was not loopback, including the Network: URL pyxle dev --host 0.0.0.0 prints at startup and asks you to use. A phone or a second laptop opening that URL got a working page and nothing else: no reload on save (JSX edits still arrived over Vite's own socket, so some edits landed and the rest looked like they had), no error overlay, and a build-failure page whose own text promises to reload itself once the rebuild succeeds, which never did. The socket now accepts exactly the origins the rest of the dev server accepts — the addresses it answers on, loopback and private-network, on its own two ports — and no others, since it carries source paths, stack traces and forwarded server logs. A refused connection is now named in the terminal instead of being closed silently. Dev server.

  • A page that cannot load its own JavaScript stops failing silently. Opening a dev page at an address the server does not serve modules to — a hostname, a public IP, an https tunnel — produced a rendered, inert page and total silence everywhere. The dev server now warns on the first such request, naming the origin and what will happen ("it will render and never become interactive"), once per origin; and the dev document reports any module that failed to load to the browser console. Dev server, pyxle dev.

  • Docs: <Form> on a shared-cached page is now a documented limitation. A page made cacheable with revalidate is stored once and served to many people, so Pyxle suppresses the per-user CSRF token — and <Form> renders without its hidden _csrf_token field on the server, then adds it on the client once it can read the cookie. That produces a React hydration warning and one re-rendered subtree. With JavaScript the form still works (submissions authenticate with the x-csrf-token header); without JavaScript, on such a page, the submission is rejected, because there is no token it could honestly carry. Keep the form on an uncached route. Caching, <Form>.

  • A page that no longer compiles stops pretending to work. pyxle dev kept the previous pass' compiled output when a rebuild failed, so a page with a syntax error carried on answering 200 with the last version that built — the browser looked healthy while the file was broken. That route now serves the compile error instead: the file, the line and column, the message, and the source around it, on the same document the 500 path already used. It reloads itself once the rebuild succeeds. A broken layout.pyxl takes down the pages it wraps and nothing else. Dev server.

  • A JSX typo fails the rebuild instead of logging a green tick. A syntax error in the React half compiled "successfully" — ✅ Rebuilt pages/about.pyxl in 139 ms — and then 500'd the request, because the pass that reads <Head>/<Script>/<Image>/<Suspense> discarded the parse failure and returned empty metadata. The page's <Head> was silently dropped with it. It is now reported like any other compile error, against the .pyxl source you edit rather than the generated .jsx: Rebuild failed: pages/about.pyxl:16: JSX syntax error: Unexpected token, expected "jsxTagEnd". This costs no extra time — that pass already ran on every compile and already had to parse the section. Dev only; pyxle build is unchanged.

  • A rebuild failure names the file in the terminal. It read Rebuild failed (0.00s): Line 7: unexpected indent, which does not say which of your pages it is about. It now reads Rebuild failed: pages/about.pyxl:7:9: unexpected indent — the location form terminals linkify and editors jump to.

  • One broken file no longer freezes hot reload for the whole project. A build pass stopped at the first file it could not compile, so with pages/about.pyxl broken, edits to pages/index.pyxl never reached the browser. A pass now builds everything it can, reports every file it could not, and reloads the half that succeeded.

  • The dev error overlay survives a page reload. An error was broadcast once, to whichever tabs were connected at that moment; reloading dismissed it and the next connection was told nothing. The current error is now replayed to every client that connects, and retracted when the route succeeds.

  • pyxle dev starts on a project that has a broken page. A file that would not compile aborted startup, taking every working route with it. The failure is reported and the server starts; only the routes that depend on the broken file answer with the error.

  • Rebuild log lines no longer name editor scratch files. Editors that save through an in-tree temporary file produced Rebuilt pages/index.pyxl, pages/sedo1AOsO in 146 ms. Only files Pyxle actually builds are listed.

  • Editing a private helper module now takes effect. A change to pages/api/_shared.py — or any .py under a dev.watch directory — produced no terminal output and no reload: the helper is not a build artifact, so the pass reported no changes and the dev server never re-imported the endpoints that import it. The endpoint went on serving the helper's old values until an unrelated file was edited. Such a change is now reported as Reloaded pages/api/_shared.py in 9 ms and applies immediately. An endpoint that 404'd because its helper did not exist yet also comes up as soon as the helper is added. API routes.

  • A route table that could not be refreshed says what is actually true. The warning told you to restart pyxle dev. It now says the previous route table is still serving and that the change has not taken effect — the next successful change applies it, no restart needed.

  • An untitled page is titled after your app, not after Pyxle. A page with no <title> in its own <Head> and none in any layout used to fall back to <title>Pyxle</title> — so a newcomer's About and Docs tabs read as the framework's name in their product's window. The fallback is now the app's own name, and pyxle.config.json gained an optional top-level "name" for it (pyxle init writes yours). With no name set, the project directory is used. The full order is page <Head> → layout <Head>name → project directory. Head management.

  • The default 404 is a designed page that tells you how to replace it. An unmatched URL was answered with Starlette's nine-byte text/plain body, which reads as if the server fell over rather than as a page that simply isn't there — a stark contrast with the designed document the 500 path already served. Both now share one document. Under pyxle dev it also names pages/not-found.pyxl as the file that replaces it; that hint is dev-only, and adding the file still takes precedence exactly as before. Clients that did not ask for HTML (fetch, API consumers) keep the plain body. Error handling.

  • pyxle check: an unused import is a warning, not a build-breaking error. Every semantic finding was reported as an error and exited non-zero, so a leftover import json failed the command that the deploy checklist gates a release on. Findings are now split by whether the code will actually break when it runs: unresolved references, malformed format strings and syntax errors stay errors and still exit non-zero; unused imports, dead locals, duplicate dict keys and the rest are warnings and exit 0. A rule Pyxle does not recognise is treated as a warning, so a linter upgrade can never become a surprise deploy blocker. Pyxle Studio's diagnostics panel picks up the same split. CLI.

  • pyxle routes no longer lists special files under URLs they don't serve. error.pyxl was printed as /error, inviting you to visit an address that always 404s. error.pyxl, not-found.pyxl and loading.pyxl are now grouped under "Special Files (no URL of their own)", each labelled by what it does and by the URL subtree it covers. CLI.

  • pyxle install prints its own output, not pip's. A successful install dumped dozens of Requirement already satisfied lines plus pip's own upgrade advert before the tidy success line. Installer output is now captured and shown only when it matters: a failure replays everything the tool said, verbatim, before the error, and pyxle -v install streams it live. CLI.

  • pyxle init checks the target before it asks you anything. A name that isn't filesystem-safe, or a directory that already exists, was only reported after every interactive prompt had been answered — and the answers were then discarded. Both are now checked up front. A mistyped import alias is also corrected in the prompt itself instead of aborting the command. CLI.

  • Fix: pyxle init apps/my-app no longer creates a directory called apps-my-app. The argument is a project name, and any path given instead was slugified — every separator became a hyphen, so the project appeared in the current directory under a mangled name, silently, nowhere near where you pointed. An absolute path produced a directory named after the whole path. An argument containing a path separator is now rejected with a message naming the two forms that work. CLI.

  • Fix: background work scheduled by an @action is no longer discarded when the action refuses. A task registered with request.state.background.add_task(...) was attached only to a successful response, so an action that scheduled work and then raised ActionError lost it — the client got its 4xx, the work never ran, and nothing recorded that it had been asked for. The audit record written whether or not a transfer clears, the failed-attempt counter, the alert on a rejected webhook: all silently gone, on exactly the path where they matter most. Work scheduled before the action raised now runs. add_task is a statement that executed, and a later raise no more undoes it than it rolls back a database write on the line above; cookies set by the action already survived a refusal on this same path, and pyxle.tasks.enqueue already dispatched at the moment it was called, so the two mechanisms had been disagreeing about the same intent. Where you schedule the task is the control and is visible in the code: above the check for work that must happen either way, below it for work that must not happen on failure. An unhandled exception is deliberately left as it was — that is a bug rather than an answer, the action's intent is unknown, and neither its tasks nor its cookies are applied. Background tasks.

  • Fix: two pages whose file names differ only by route syntax no longer serve each other's content. A compiled page is cached under a key derived from its path relative to pages/, and that key had the route syntax stripped out of it for readability — so [id] and id, [[...slug]] and [slug], (marketing) and marketing, my-page and my_page, embed.js and embed_js each reduced to one name. Two such pages shared a single entry in the import cache, and because a cached module is reused without re-checking which file it came from, whichever page was imported first answered for both: the second URL rendered the first page's component with the first page's loader data, at 200, with nothing in the logs and no error anywhere. Route groups made this ordinary rather than exotic — their entire purpose is a directory that does not appear in the URL, so (marketing)/pricing.pyxl and marketing/pricing.pyxl are two legitimate pages at two legitimate URLs, and one of them was unreachable. A page name the stripping alters now carries a short digest of its original text, so no two source files can share a key; names that survive stripping untouched — nearly all of them — keep exactly the keys they had. The same applies to API modules. SSR pipeline.

  • Fix: pyxle openapi and pyxle routes --json no longer write their errors into the file you redirected them to. Both commands emit a document on stdout, so the documented way to use them is a redirect or a pipe — and every message they produced, including every failure, went to stdout as well. pyxle openapi > openapi.json on a project that could not produce a schema therefore wrote the error message into openapi.json and printed nothing in the terminal: an artifact that is not valid JSON, a command that looked like it had worked, and no visible reason anywhere. Redirecting a failing run now leaves an empty file and puts the message on stderr where the human can see it, and a successful run's stdout is the document alone — no banner, no step lines — so pyxle openapi | jq works without filtering. pyxle routes without --json prints a table for a person to read and keeps stdout as before. CLI.

  • Fix: pyxle openapi no longer refuses to run on a project that needs no Pydantic. The command required the optional [pydantic] extra before it looked at your project at all, so on a machine without it every project failed — including a freshly scaffolded one, which contains no @action whatsoever — with a message asserting that "this action validates its request body with a Pydantic model". There was no such action: the wording belongs to the request-validation path, where it is accurate, and was being raised where nothing had been inspected yet. Pydantic is now required only where a model actually has to be resolved, so a project whose actions declare no body — or which has no actions yet — generates its document with the extra absent, an empty paths object being the right answer for a project with no actions. When an action does declare a model body and Pydantic is missing, the command still stops, and the message now names the action and the file it lives in instead of leaving you to find it. CLI.

  • Fix: error.pyxl now catches an ordinary exception from a loader, not only a LoaderError. The error boundary was reached for four framework-recognised failures — an author-raised LoaderError, a loader Pyxle could not run or whose return value it rejected, a bad HEAD, a failed component render — and only those. Anything a loader's own body raised propagated unclassified and landed in the guardrail meant for unexpected framework faults, which returns Pyxle's fallback document without consulting the boundary at all. So the most ordinary bug in the world — a missing dict key, a None where an object was expected, a database driver's exception — showed the framework's "Server Error" page on a site that had written its own. The gap was invisible while building: a developer testing their error.pyxl naturally raises LoaderError, sees their page, and ships. An exception escaping a loader is now classified where it happens, as the loader-stage failure it is: the nearest error.pyxl renders with status 500, the dev overlay's breadcrumbs mark the renderer as blocked by the loader rather than "outcome unknown", and the original exception is chained so tracebacks still point at the line in your .pyxl file. The same applies to a loader on a layout.pyxl. What reaches the visitor is unchanged in kind — the boundary receives the real message in development and a generic one in production, and an author-raised LoaderError still passes through with its own status code and its own wording in every environment. A fault in Pyxle's own render pipeline deliberately still bypasses the boundary: handling a framework fault by running more application code can compound the failure. Error handling.

  • Fix: a 500 that renders through error.pyxl is written to the server log. The failure was logged only when the fallback document was served, so an application that shipped an error boundary silently swallowed its own server errors — and because a production response is deliberately sanitized, that log is the only record of what broke. The boundary was hiding exactly what it exists to survive. The failure is now recorded before the boundary is attempted, once, with its traceback. Intentional sub-500 statuses stay quiet as before.

  • Fix: an @action that crashes in production is written to the server log. A loader that raised logged a full traceback; an action that raised logged nothing at all — the dispatcher converts an exception into a JSON 500 itself, so it never reached the handler that does the logging, and only one narrow sub-case (an unset request.state attribute) had been given a log line of its own. Because the production response is deliberately sanitized, this left an action with no record anywhere: the developer saw {"ok": false, "error": "..."}, checked the log, found an empty file, and had nothing to debug from. Measured around a single failing request, the log grew by zero bytes. Every 500 the action dispatcher can return is now recorded before the response is built — a raised exception, a module that will not import, an unset request.state attribute, an action returning something other than a dict — once each, with the traceback and the name of the action that failed. ActionError stays quiet: a refusal is the action's own answer to its caller, not a server fault, and keeping it out of the error log is what makes a real crash findable. Behaviour change: the sanitized message an action returns in production is now "An unexpected error occurred.", matching the wording a page's error boundary has always used and the one Error handling documents; it was previously an undocumented "Internal server error". Anything asserting on the old string needs updating. Server actions.

  • Fix: an error page is no longer served without a head. A page rendered through an error.pyxl boundary took that file's HEAD variable verbatim and nothing else, skipping the merge every other page goes through — so the page a confused visitor is most likely to see arrived without the site's stylesheet, favicon, description or title, and without its own <Head>. It rendered as unstyled black-on-white text in the browser's default serif, on a site that looks nothing like that. The boundary now merges the same four sources a normal render does: the layout chain's <Head> blocks and its already-evaluated HEAD variable, the boundary's own HEAD, and the <Head> elements its render produced — with the usual precedence, so an error page's <title> still beats the layout's. A HEAD that has to be evaluated is handed the error context rather than loader data; if it raises, the head degrades to what could be extracted statically and the boundary still reaches the visitor. Error handling, Head management.

  • Fix: pyxle build no longer reports success when it could not build your client bundle. A build that cannot reach npx — a machine with Node.js but no npm, the shape apt install nodejs leaves behind, and the slim CI images and Docker bases built the same way — logged a one-line warning, skipped Vite, and exited 0 under a green "Build completed" banner. The dist/ it left held no browser JavaScript and named each page by its dev-server path, which pyxle serve refuses at startup as an unsafe path — so the first sign of trouble was a deployment that would not boot, reported as a path-safety error that said nothing about Node. The build now stops where the bundle cannot be produced, names the missing prerequisite and the command that fixes it, and exits non-zero; a project with no package.json is refused the same way. Nothing is written to dist/ when it fails, so a rebuild that cannot succeed leaves the previous deployment in place rather than replacing it with an unservable one. As a backstop, the page manifest is checked against the same rule pyxle serve applies before it is written — a build that reports success is a build that can be served. Deployment.

  • Fix: a CSRF token, signed cookie or bearer credential containing a non-ASCII character is now rejected rather than answered with a 500. The constant-time comparison Python provides is only defined for ASCII text and raises on anything else, so a value that should simply have failed to match instead escaped as an unhandled error. Every position this affected takes its value straight off the wire — the X-CSRF-Token header, the _csrf_token form field (urlencoded or multipart), the CSRF cookie, a pyxle.verify_cookie argument, and the metrics endpoint's Authorization header — and a browser reaches all of them in ordinary use, since a form field holding an accented character is percent-encoded UTF-8 by the time it arrives. The cookie case was the durable one: a CSRF cookie carrying such a value is read on every request including plain page views, so a single bad cookie made the site answer 500 to that browser until it was cleared. All of these are now ordinary mismatches — 403 for a bad CSRF token, 401 for a bad bearer token, None from verify_cookie — and a CSRF cookie that could not have been issued by the server is replaced with a fresh one instead of being trusted.

  • Fix: the CSRF cookie the server sets is never assembled from the value a request supplied. The middleware reuses a still-valid cookie rather than minting a new token on every response, which keeps concurrent requests from invalidating each other's token. Cookie values pass through an unescaping step that can reconstitute ; and =, so on a server with no PYXLE_SECRET_KEY configured — where there is no signature to check the value against — a request could steer the attributes of the cookie the server then set on itself. Reuse now requires the value to have the shape the server issues, and the deliberate reuse behaviour is otherwise unchanged.

  • Fix: cors.maxAge rejects true and false. bool is a subclass of int in Python, so a boolean satisfied the integer check and bound a 1- or 0-second preflight cache with no error — the config was wrong and nothing said so. It now raises like the other integer settings already did.

  • Fix: pyxle serve reads the build it serves. Routing metadata came from the intermediate .pyxle-build/ cache while the artifacts came from dist/, so the two could disagree — and a page's client_path is one of the fields that disagrees, because the compiler writes it and the layout composition pass rewrites it. Anything that recompiles a page after a build (a project's own test helper, an editor tool, an interrupted pyxle dev) reset that page to its unwrapped module, and production then rendered it without its layout: no wrapper markup on the server, the bare page bundle hydrating on the client, no error on either side — while the layout's @server loader kept running and its data kept arriving in the hydration payload, so the page looked correctly wired from everywhere except the markup. A deployment that shipped only dist/ had the sharper version of the same bug: no .pyxle-build/ to read, so every route 404ed. dist/ now carries meta.json and is self-contained, and pyxle serve re-roots onto it. Deployment.

  • Fix: a deployment that ships only dist/ boots. dist/ carried every compiled module but none of the source files a running server still reads — and that gap sat exactly where the framework tells you to put helpers. A private module colocated with your endpoints (pages/api/_shared.py, pages/api/__init__.py, anything under pages/api/_internal/) is deliberately not a route, so nothing compiled it into dist/server, while the endpoint whose first line is from pages.api._shared import … was compiled and shipped. Deploy dist/ on its own — a Docker COPY --from=build, a CI artifact, an rsync of the build output — and that import had nothing to resolve against. It runs while the route table is assembled, so the server did not start at all: every route went down, not just the endpoint that owned the helper. The same gap silently disabled per-directory pages/**/llms.py handlers and colocated pages/**/*.md, and a configured styling.globalStyles entry aborted startup outright, since its source is read and inlined into every rendered document. pyxle build now copies those files into dist/app and pyxle serve falls back to them, so a dist-only deployment behaves like one carrying the whole repository. A deployed source tree still takes precedence over the copy. Application code that lives outside pages/ — a project-root db.py, a middleware.py named in your config — remains yours to ship. Deployment.

  • Fix: an intentional 404 no longer renders as "Server Error". A loader raising LoaderError(status_code=404) is stating a fact about the request, not reporting a fault — but every sub-500 status rendered the 500 document, so a visitor who followed a stale link was told the server had failed and asked to try again later. That sends them to complain to the wrong people, or to wait for a recovery that is never coming. The status now chooses the wording, by class rather than by a list someone has to remember to extend: a 4xx says something about the request, so it is never reported as the server failing, whether or not Pyxle has specific wording for it. 400, 401, 402, 403, 404, 405, 408, 409, 410, 422, 429 and 451 each have their own; any other 4xx says the request was not accepted; 5xx keeps the opaque server-error text, because there the server really is at fault and "try again later" is honest advice. A rate-limited visitor being told the server broke was the case that made the difference plainest. Nothing else about the error is disclosed — the exception type, its message and the route path are still withheld in production. An application should still answer a missing resource itself, with its own layout and a way onward; this is the floor, not the intended experience.

  • Fix: a <Head> tag or <Script> containing a JSX expression no longer reaches the browser unevaluated. <Head> blocks and <Script> declarations are extracted from .pyxl source at compile time, before any of it has run, so anything referencing props — <link rel="icon" href={faviconUrl} /> — was emitted as literal source text. The browser then read href="{faviconUrl}" as a relative URL and requested it, one failed round trip per such tag per page view, while og:image unfurled as a broken picture in every chat client that read it. Deduplication could not suppress it, because a link's key is its rel plus href and href="{faviconUrl}" never matches the rendered one — so the broken copy and the real one were both emitted. This reached further than an attribute value: <title>{name}</title> put the literal braces in the browser tab (and on a streamed page, where the head is flushed before the component renders, that was the only title the page got); <Script src={url} /> had the browser request the literal text as a URL; <script type="application/ld+json">{JSON.stringify(schema)}</script> shipped a second, unparseable copy of the page's structured data; and an element behind a condition — {isPremium && <meta name="robots" content="noindex" />} — was emitted whether or not the condition held. A statically extracted element is now dropped whenever any part of it still holds an expression, in an attribute, in its child text, or around the element itself; the render supplies the same elements with their values in them. Literal braces are untouched: a content="Uses {braces} in prose" attribute, a JSON-LD payload, a <style> rule and an inline script all still reach the document verbatim. Head management.

  • Fix: a layout's HEAD variable is emitted verbatim, not filtered as if it were JSX source. A layout contributes to the head in two ways — a <Head> block, which is source text extracted before it runs, and a Python HEAD variable, which has already run and is finished HTML — and the two were being collected into one list, then filtered by the rule that only the first kind needs. Brace-heavy content in a root layout.pyxl's HEAD was therefore dropped from every page beneath it: site-wide JSON-LD, a critical-CSS <style>. Nothing restored it, because a HEAD variable is never rendered by React, so unlike the <Head> case there was no evaluated copy behind it. The two now travel as separate channels; a layout's <Head> still wins over its own HEAD variable on a shared key. This was introduced by the expression fix above and is corrected in the same unreleased set — no published version of Pyxle ever behaved this way. Head management.

  • Fix: a render-time build error names your .pyxl, not the .jsx Pyxle generated from it. A failure raised while the SSR worker bundled a component reported the compiled artifact — pages/about.jsx:8:8 — a path the developer never created, inside a build directory they had no reason to know exists. The line number was not theirs either: a page's JSX half starts wherever its Python half ended, so JSX line 1 is routinely line 19 or line 40 of the .pyxl. Opening the named file at the named line landed on unrelated code, which made the error read as nonsense rather than as a pointer to the typo. Positions are now translated back through the line map the compiler already writes for every page, so the message reads pages/about.pyxl:26:8 and points at the line the author would fix. Where a position genuinely cannot be mapped — an unknown module, or an error inside code the compiler emitted rather than code anyone wrote — the message says so, marking the position (generated) or naming the page and stating that the position belongs to the generated output. It is never silently presented as the author's own file: a reader who knows a position is approximate can work with it, and one who thinks about.jsx is their file cannot. The translation happens where the error is raised, so the terminal log, the dev overlay and the error document all show the same corrected location.

  • Fix: production no longer ships every stylesheet twice. A built page linked each stylesheet Vite compiled for it and embedded the same rules again in an inline <style> block, because the SSR worker decided whether to inline by asking whether the project had configured PostCSS or Tailwind — a stand-in for "does Vite deliver CSS here?" that is false for a plain-CSS project and beside the point under pyxle build, where the answer is always yes. On the default scaffold that was 1,858 bytes of inline CSS beside 1,510 bytes of linked CSS, every selector present in both. It bought nothing: the <link> tags are render-blocking with no media or onload swap, so the browser waited for the linked copy regardless and the inline one could not speed up first paint. The worker is now told directly whether the document links the stylesheets itself, from the same function that decides whether to emit those links, so the two cannot disagree. The scaffold's home page dropped from 5,035 to 3,085 bytes — about 39% of the document — on every request, uncached. pyxle dev is deliberately unchanged: there is no build manifest, Vite injects CSS through the client bundle after hydration, and the inline copy is the only thing that styles the server-rendered paint. A Tailwind or PostCSS project keeps the behaviour it had in both modes, and CSS Modules resolve to their hashed class names exactly as before. SSR architecture.

  • Fix: a layout's HEAD is evaluated, not just scanned for literals. A layout.pyxl reached the document only when its HEAD was written as a literal string or a literal list. Anything the compiler could not read straight off the source — an f-string, a concatenation, a comprehension, a json.dumps(...) call, a def HEAD(data) callable — produced nothing, with no warning, no log line, no build error and no pyxle check complaint, while the identical HEAD one directory down in a page worked perfectly. That split is what made it so hard to see: the form was documented, it demonstrably worked, and it silently stopped working the moment you moved it up a level to apply site-wide. The content most likely to be affected is the content most worth putting in a layout — site-wide JSON-LD is normally built with json.dumps, which is precisely the shape that disappeared, so a site could serve every page with structured data that existed in the source and in no response. A layout's HEAD is now resolved exactly the way a page's is, in every form; a callable receives that layout's own loader data, mirroring the page contract, and a layout with no loader receives an empty dict. Each layout in a chain is handed its own loader's return value rather than the merged data its component receives, so an outer layout cannot change what an inner layout's head says. The fix covers every path a head reaches the browser through — first load, streamed pages, client-side navigation, pages rendered through an error.pyxl, and pages pre-rendered by pyxle build --static. A HEAD that cannot be evaluated is now a server error naming the layout file rather than a page quietly missing its tags, except when rendering an error boundary, where it degrades to whatever could be read statically so the boundary still reaches the visitor. Head management.

  • Breaking: a HEAD entry holding more than one element is now an error instead of silently losing all but the first. Before, HEAD = '<title>My Page</title><meta name="description" content="D" />' compiled, deployed, and served the <title> alone — the <meta> was discarded with no warning, no log line and no build error. After, the same line fails the build with the file, the line and the dropped markup, and tells you to split it into separate list entries: HEAD = ['<title>My Page</title>', '<meta name="description" content="D" />']. Each entry is parsed and rebuilt from its first element, which is the same pass that discards markup injected after an attribute quote breakout — a security boundary, so the fix is to split the entry rather than to loosen the sanitiser. Anyone this breaks was already broken: the second element has never reached a single visitor, and the upgrade converts that silent loss into a build error that names exactly what was missing and how to fix it. Our own documentation taught the two-elements-in-one-string shape, so copying the docs was enough to hit it — that example is now a list. Where the entry is computed rather than literal, its value is only known while the page renders, so it is a logged warning naming the file and the dropped markup instead of an error: losing a <meta> must not cost the visitor the page, and a value that depends on data could otherwise take a page down for only the requests that reach that data. The warning is logged once per distinct problem, not once per render. pyxle check reports the static case as a positioned diagnostic. Head management.

  • Fix: a relative link now resolves against the current page, not the site root. <Link href="?days=7"> — a filter, a tab, a sort control, anything that changes only the query — resolved against window.location.origin, so clicking it navigated to /?days=7 instead of staying put. ../ and other relative references were wrong the same way. The prefetch cache keyed on the same wrong base, so its entries named pages the click never loaded. Relative references are defined against the document's own address (RFC 3986 §5), and now behave that way.

  • Fix: the CSRF cookie is only marked Secure when the request actually arrived over HTTPS. Production marked it unconditionally, and a browser discards a Secure cookie sent over plain HTTP — so every plain-HTTP production server (a LAN demo, an evaluation box, anything behind a proxy that omits X-Forwarded-Proto) rejected its own sign-in form with "CSRF token missing" and no way to diagnose it. HTTPS deployments are unchanged, X-Forwarded-Proto is honoured for the usual TLS-terminating-proxy shape, and the flag is not a loss over HTTP: a connection with no confidentiality has no cookie confidentiality to protect.

  • Fix: HEAD is now allowed on any API route that allows GET. RFC 9110 defines it as identical to GET without a body and Starlette already routes it to the GET handler, but the default method policy answered 405 — so a route advertised as GET refused every curl -I, link checker and health prober that used HEAD. A route that does not allow GET still refuses HEAD, because there is nothing for it to mirror.

  • An @action can set a cookie. request.state.cookies.set(...) / .delete(...) record a Starlette set_cookie/delete_cookie call, applied to the response the action's returned dict becomes — including on an ActionError, since a refusal may still need to clear a session. Previously an action had no response to reach, so anything cookie-shaped (a session, a preference, a consent record) had to be rewritten as an API route, splitting a page's own mutations across two places. Server actions.

  • Fix: a public/ file whose URL begins with client is served instead of returning 404. The framework serves its build output under /client/, and the check that reserves that namespace compared leading characters rather than whole path segments — so public/client-logo.svg, public/clients.json, anything named client…, was mistaken for a bundle and never looked for in public/ at all. Under pyxle dev every such file 404ed. In production the in-memory static cache answered the small ones before the check ran and left the large ones to fail, so the same site served a logo and lost a hero image, with nothing in the logs either way. Reserved namespaces are now matched a segment at a time, here and in the dev server's /__pyxle paths.

  • Fix: an API route whose URL ends in an asset suffix is no longer swallowed by the dev server's Vite proxy. pages/api/embed.js.py — an embeddable widget, the shape status pages and analytics products ship — was forwarded to Vite on the strength of the .js suffix alone, which answered with the app's index HTML. The endpoint worked under pyxle serve and silently returned a web page under pyxle dev. The app's own routes now win; genuine assets, including everything under /@vite, are unaffected.

  • api directories may now sit at any depth under pages/. A .py file is an endpoint whenever the URL it maps to contains an api segment, so pages/s/[slug]/api/v2/summary.json.py serves /s/{slug}/api/v2/summary.json. Previously only a top-level pages/api/ counted, which left a per-tenant compatibility API — a path shape another vendor's clients already expect — with nowhere to live. .py files outside an api directory are still ignored by routing, so helpers stay safe to colocate. Note that a nested directory named api now publishes its .py files and stops shipping its client assets; rename it if it was holding neither. The client router reads the same rule off the URL, so a link to an endpoint at any depth is left to the browser: it is never prefetched on hover — a prefetch is a GET at your endpoint, issued from a mouse movement, which for a non-idempotent or expensive one is a side effect nobody asked for — and a click on it performs an ordinary navigation instead of being turned into a client-side one, which is what puts the endpoint's response on screen rather than feeding it to the page router. The same rule governs the .md renditions the AI accessibility feature serves: a link to an api path keeps pointing at the endpoint. An api directory holds no pages, either: a .pyxl file in one is now refused when the project is scanned — by pyxle dev and pyxle build alike — with an error naming the file and telling you to rename the directory. It used to publish as a page while the Chart.jsx beside it was dropped from the client build, so the page's own import failed in Vite as an unresolvable path inside .pyxle-build/ that never mentioned api; the directory was reserved enough to break your components, not reserved enough to stop you putting a page there. Only directories count, so pages/api.pyxl is still an ordinary page, and a page URL containing api is still yours to serve — pages/docs/[...slug].pyxl answers /docs/api/config — it simply loads as a full page rather than a client-side transition, since the client decides from the URL alone. API routes.

  • Fix: a .py file whose name starts with an underscore is no longer an API route. Every .py file in an api directory was registered as an endpoint, and an API module that exports no endpoint, no websocket and no HTTPEndpoint subclass is a hard startup error — so putting pages/api/_shared.py next to the endpoints that import it, or letting a tool drop an __init__.py there, stopped the app from booting at all. Python's own convention now decides: a leading underscore on a file or a directory means private, so _shared.py, __init__.py and everything under _internal/ serve no URL and stay importable by the endpoints beside them. Only the segments at or below the api directory are read this way — pages/_admin/api/health.py still serves /_admin/api/health — and .pyxl pages are unaffected. API routes.

  • STANDALONE = True on a layout makes it the root of its own chain. Pages beneath it are not wrapped by ancestor layouts, ancestor layout loaders do not run for them, and ancestor <Head> blocks do not land on them. The case is a section that is not part of the app around it — a public status page inside an admin console, a print view, an embedded widget — where the alternative was branching inside the outer layout on a path check, a conditional that grows with every such section and puts knowledge of each one in the parent. Layouts.

  • Fix: pyxle dev no longer reports "ready" for a server that failed to start. The ASGI lifespan — where plugins connect to databases and validate their settings — runs after the banner was printed, so a plugin whose on_startup raised produced a green "ready in 646 ms", then a silent exit. The banner now waits until the server is genuinely accepting connections, and a boot that dies prints nothing but the reason.

  • Fix: server-side errors during pyxle dev reached the terminal again. Forwarding server logs to the browser console attached a handler to the root logger, which ends Python's built-in "no handlers → print warnings to stderr" fallback. Any library warning or error with no handler of its own — including a failed startup's traceback — went only to a browser console that may not have been open. The dev server now keeps a stderr sink for WARNING and above, exactly as Python does without it.

  • Fix: editing a @server loader no longer strips every stylesheet from the page it rebuilds. A component registers its imported stylesheets as a side effect of its module being evaluated, and Node evaluates a given module URL once per process. Editing only the Python half of a .pyxl file leaves the emitted client bundle byte-identical, so the rebuild's re-import was a module-cache hit that skipped that registration and recorded no styles at all — the page then server-rendered with its CSS missing, and stayed that way until pyxle dev was restarted. What made it easy to hit and hard to place is that it is the first edit the Quick Start asks for (change the message a loader returns) and that the browser hides it: Vite re-injects the same CSS after hydration, so the page settles correctly and only flashes unstyled on the way. Descriptors are now remembered against the bundle's content hash, so a re-import that skips evaluation reuses the styles that bundle registered, while a bundle whose imports genuinely changed hashes differently, evaluates, and records its own.

  • Startup failures name themselves. A lifespan that raises now logs Application startup failed: <reason> through the CLI logger before aborting, with the full traceback under --verbose.

  • Docs: the debugging guide covers picking the interpreter from a .pyxl file. Both VS Code debug configurations run the dev server under the interpreter VS Code has selected, so the guide now points at the status-bar item (and the Pyxle: Select Python Interpreter command) that Pyxle Language Tools shows while a .pyxl file is open, and notes that the pre-launch check tests what the interpreter can run rather than the version its package metadata reports — an editable install with stale dist-info is no longer refused. Debugging.

  • Docs: the build architecture guide described a Vite resolver that pyxle build does not use. It documented a four-step search for a Vite executable — a local install, then npm install, then a global binary, then npx — plus a dedicated error type and streamed [vite] output that no build has ever produced. What pyxle build actually runs is npx vite build, and npx resolves the project's own node_modules copy before reaching for anything else, so a pinned Vite is honoured exactly as the page led you to expect — only the account of how it gets there was wrong. Nothing about the build changed; the page now describes it. The dev server's separate fallback order was documented in the same shape and is corrected alongside it. Build and serve, The dev server.

0.8.0#

  • Pyxle Studio — a dashboard built into pyxle dev. Served at /__pyxle/studio: every route with its loader, actions, cache posture, and boundaries; an interactive tester (loaders run in-process, actions go through their real endpoint — CSRF, validation, and auth hooks included); a live request feed; latency metrics; the effective config (secrets redacted); and in-browser pyxle check. Dev-only by construction, with a Host-header allowlist. Pyxle Studio.
  • New pyxle studio command. Runs the dev server and opens the browser on the dashboard, enabling it for that run even when the config opts out. CLI.
  • Breakpoint debugging directly in .pyxl files. Set a breakpoint on a line inside a @server loader and on a line inside the JSX below it — both bind. Compiled server modules are remapped to their .pyxl sources, so the Python debugger binds natively; dev source maps chain through Vite so the React half binds in the browser. debugpy ships with the framework — nothing extra to install. Debugging.
  • One-key debugging from VS Code. Pyxle Language Tools 0.3.0 contributes a pyxle debug type: press F5 to run your dev server under the debugger — one clean session with a real Stop button that tears the whole server down — and open your app. A separate "Debug Pyxle app (React browser)" configuration debugs the React side in a standalone browser session. Plus a "Pyxle: Open Studio" command. Install it with code --install-extension pyxle.pyxle-language-tools. Debugging.
  • pyxle dev --inspect for attach-style debugging. Hosts a debugpy server bound to 127.0.0.1 so any DAP client — or a remote VS Code — can attach to a running dev server. The server writes .pyxle-build/dev-server.json (ports, debugpy endpoint) for editor tooling and removes it on shutdown. Debugging.
  • Dev tracebacks now point at .pyxl sources. A loader/action error names your .pyxl file and line instead of the compiled module under .pyxle-build/. Debugging.
  • Breaking: /__pyxle is now a reserved URL namespace. The dev server's Vite asset proxy never forwards paths under it, so an app route under /__pyxle no longer resolves in pyxle dev (it still serves in production, where the namespace is unused). Move any such route before upgrading. Pyxle Studio.
  • Fixed on Windows: NODE_PATH is built with the platform delimiter. A project that already set NODE_PATH got a :-joined value, which Node cannot parse on Windows, so neither entry resolved.
  • Fixed on Windows: rebuild notifications report POSIX paths. The dev server's rebuild event carried host separators, so Studio and the error overlay showed pages\index.pyxl.

0.7.5#

  • pyxle dev now persists module-level state across requests, like pyxle serve. A @server/@action module is imported once and reused between rebuilds, so a module-level counter or in-memory cache no longer resets on every refresh in dev. Saving a file still re-imports it (resetting globals and applying your edits). State stays per-process — use a database or cache for anything shared or durable (Loaders should be stateless).
  • pyxle init now requires an explicit target. A bare pyxle init used to silently scaffold into the current directory; it now errors, pointing to pyxle init my-app (new directory) or pyxle init . (current directory).

0.7.4#

  • Fix: pyxle dev no longer hot-reloads in an endless loop on Linux. After a save, the rebuild's own file reads surfaced through inotify as events the watcher mistook for edits, re-triggering the rebuild forever (macOS FSEvents doesn't report reads, so it was Linux-only). The watcher now reacts only to genuine change events, not read-only opens.

0.7.3#

  • Docs: an @action's awaited result is the flat { ok, ...yourReturn } — read fields directly (res.title), never res.data.title (a successful result has no .data; useAction().data is a separate hook property). Clarified across the Server Actions guide, the client API reference, and the scaffold AGENTS.md.

0.7.2#

  • Fix: pyxle init scaffolds an installable requirements.txt again. The template's starlette pin conflicted with the framework's after 0.7.1's security bump; both now use starlette>=1.3.1,<2.0.
  • Config: a boolean port is now rejected instead of binding port 1. bool being an int subclass slipped past the validator; it now raises a clear ConfigError.

0.7.1#

  • Security: require Starlette ≥ 1.3.1, fixing a Host-header auth bypass (PYSEC-2026-161), form-parsing DoS (PYSEC-2026-249, -1943), and a Windows StaticFiles SSRF (CVE-2026-48818). Pyxle's own API is unchanged; a new pip-audit CI job guards dependencies.
  • pyxle serve refuses to start in production without PYXLE_SECRET_KEY — previously only a warning, leaving CSRF tokens and signed cookies forgeable. Security, Deployment.
  • pyxle dev/build check the Node.js version up front — a clear "Node.js 20.19+ required" message instead of an opaque Vite crash. Installation → Troubleshooting.
  • A real testing story: pyxle.testing helpers and a Testing guide. load_loader("pages/index.pyxl") / load_page(...) compile a page and hand back its loader/module for unit tests.
  • New Debugging guide and Deployment fixes — Node 22 in the sample Dockerfile, the real metrics path (/api/__pyxle/metrics), a migrations section, and a blue-green CSRF gotcha.
  • Security docs now match what Pyxle sends — it already sets X-Content-Type-Options/X-Frame-Options/Referrer-Policy, so the guide covers only CSP and HSTS; a SECURITY.md is published for every repo.
  • A fresh project now ships a human README.md alongside the AI-oriented AGENTS.md.
  • The comparison guide names current gaps — TSX authoring, a first-class test client, automatic image/font optimization, i18n — with how to bridge each today.

0.7.0#

  • Fix: error.pyxl renders even when an ancestor layout has a @server loader. Boundary renders now run ancestor layout loaders like a normal page instead of silently falling back to the built-in error document.
  • Pre-release fixes. PYXLE_SSR_WORKER_CONCURRENCY reaches the worker again, SIGTERM to pyxle dev tears down the whole child tree, a failed rebuild broadcasts to the overlay, and reverting to last-good content triggers the recovery rebuild.
  • Fix: CommonJS React libraries no longer crash SSR. The SSR bundler resolves dependencies ESM-first (like Vite), so packages without an exports map (e.g. lucide-react) link cleanly; a genuinely CJS-only package now fails with an actionable error. Third-party packages.
  • Docs: the road to a fully-typed Pyxle — where the type story is heading (TSX authoring, Python types across the boundary), grounded in what works today.
  • Breaking: Node.js 20.19+ is now required (Vite 7's floor; Node 18 is EOL). Installation.
  • Modernized scaffold — React 19, Vite 7, and an interactive pyxle init. Arrow-key prompts for Tailwind, shadcn/ui, and the import alias, with --yes/explicit flags for non-interactive use. Quick Start.
  • Tailwind is now opt-in — Tailwind v4 wired into Vite when chosen. No config files or standalone watcher; decline it and plain CSS / CSS Modules work out of the box. Styling.
  • shadcn/ui support — choose it at pyxle init and npx shadcn@latest add … works with no shadcn init; the @ alias resolves in the client build and SSR. Third-party packages.
  • Fix: streaming SSR no longer serializes across requests. An SSR worker now renders many requests concurrently (isolated per-request with AsyncLocalStorage), and pyxle serve auto-sizes its worker pool. Streaming.
  • pyxle init renders the framework pin from the running version instead of a stale >=0.4.1 that downgraded fresh projects.
  • pyxle typecheck fails fast when TypeScript isn't installed, with an actionable message instead of npm's placeholder tsc. TypeScript.
  • Scaffolded AGENTS.md correctedLoaderError (like server/action/ActionError/…) is compiler-injected, not imported.
  • pyxle check works out of the box — pyxle-langkit is now a default dependency, so JSX checking passes on a fresh scaffold with no extra step.
  • Fix: the CSRF cookie no longer collides between apps on one host. The default name is namespaced by bind port (pyxle-csrf-8000) and injected into the page shell. Security → CSRF.
  • Fix: multipart forms (file uploads) now pass CSRF with a _csrf_token field. The middleware stream-parses just far enough to read the token (capped at 1 MiB) and replays the body untouched. Security → Form bodies and uploads.
  • pyxle dev output is clean by default, --verbose for the firehose — a curated startup summary and one-line rebuild notices instead of Vite's raw stdout. CLI.
  • Server-side logs stream to the browser console in pyxle dev, prefixed [pyxle:server]; dev-only and bounded. CLI.
  • public/ changes are picked up on refresh instead of triggering a rebuild (matching Next.js) — assets serve live from disk, and a newly added or removed file refreshes the static index. Architecture → the watcher.
  • Watch extra directories with dev.watch, ignore paths with dev.ignore. A shared module outside pages/ can now trigger hot reload; dev.ignore is additive to the built-in ignores. Configuration → Development.

Documentation#

  • Fixed the flagship WebSockets example — it derived the socket path from window during SSR and 500'd; it now builds the path from loader data.
  • More doc fixes — a broken @action example in the pyxle-db docs (await request.json()), documented CSRF on the pyxle-auth endpoints and where pyxle-auth accounts live, an honest pyxle check scope note, and prerequisites up front in the Introduction.
  • Accept: text/markdown negotiation now follows RFC 9110 (q-values honoured, exact type match); new markdown_is_acceptable(accept) helper. AI accessibility.
  • /llms.txt now emits absolute URLs and links .md only where Markdown actually resolves. AI accessibility.
  • Breaking: converted Markdown rewrites internal links to .mdhtml_to_markdown() rewrites by default (rewrite_links=False for the old behavior). AI accessibility.
  • Fix: a burst of rapid saves can no longer kill the dev server — builds are serialized, meta.json writes atomically, and the Vite subprocess is supervised with bounded backoff.
  • Fix: reading an unprovided request.state.<name> (e.g. request.state.db without pyxle-db) now gives a structured, guided error instead of a bare AttributeError.
  • Actionable SSR error when a component touches a browser global (window, document, …) — dev names your .pyxl file and the fix (useEffect/<ClientOnly>); production stays generic. Client Components.

0.6.1#

A sharper pyxle check — the edit → check → fix loop now catches classes of mistake it used to wave through.

  • pyxle check gained a semantic layer — pyflakes over the Python section flags undefined names, unused imports, and redefinitions (compiler-injected names are recognized).
  • Duplicate export default is now caught at check time, at the real source line, instead of failing later in the build.
  • JSX error lines are now accurate, and Babel's misleading "unterminated regex" carries a plain-language hint about the real cause.
  • LoaderError and invalidate_routes are now auto-injected, matching ActionError/ValidationActionError. Runtime API.
  • pyxle install --break-system-packages for externally-managed (PEP 668) environments. CLI.

0.6.0#

  • AI accessibility — serve your app as Markdown, plus llms.txt. Opt in with "llms": true and every page gains a .md rendition (and Accept: text/markdown support), an /llms.txt index, and discovery headers; Markdown resolves from a co-located <page>.md, a to_markdown handler, or an ancestor llms.py, with an autoConvert HTML→Markdown fallback. Off by default, and adds nothing to the page hot path. AI accessibility.

0.5.0#

The depth release: caching/SSG/ISR, streaming SSR, realtime (WebSockets + a cross-worker Redis broker), Pydantic-validated actions, observability, background work, image optimization, and multi-worker serving — built, documented, and dogfooded on pyxle.dev. The two behavior changes below are called out explicitly.

  • Cross-worker realtime — a Redis pub/sub broker. WebSocket channels span worker processes and machines with PYXLE_REALTIME_BROKER=redis; the in-process broker stays the default. No code change. WebSockets.
  • Fix: streaming SSR now survives production gzip. A streaming-aware StreamingGZipMiddleware flushes the compressor per chunk so the shell reaches the browser first. Streaming.
  • Fix: dynamic pages are no longer client-nav-cached with stale data. The navigation-cache TTL now mirrors server cacheability, so a dynamic page always refetches on back/forward. Caching.
  • Fix: pyxle build --static pre-renders pages whose loaders use a plugin. The static builder stands up the same plugin context a request sees, so a DB-backed loader runs at build time. Caching.
  • Changed (behavior): stricter nested-config validation. The cors/csrf/observability/… blocks now reject unknown keys at boot, so a typo'd security key fails loudly instead of silently no-opping. Configuration.
  • Fix: error.pyxl no longer leaks internal error details in production — the boundary gets a generic message and sanitized type; author-raised LoaderError/ActionError messages still pass through. Error Handling.
  • Fix: import.meta.env.PYXLE_PUBLIC_* is now substituted during SSR, so a public env var no longer causes a hydration mismatch. Environment variables.
  • Fix: scaffold .gitignore no longer ignores .env, matching the env-vars doc.
  • Fix: <Image> emits a lowercase fetchpriority attribute (React 18.3.1 rejected the camelCase form).
  • Fix: production gzip no longer prints I/O operation on closed file — the compressor closes deterministically on every path.
  • Fix: scaffolded jsconfig.json drops the deprecated baseUrl in favor of tsconfig-relative paths.
  • Fix: pyxle typecheck works on current TypeScript"bundler" resolution, no baseUrl.
  • Startup warning when a BaseHTTPMiddleware is paired with streaming routes (it buffers responses, breaking streaming SSR). Middleware.
  • Clear compile-time error for TypeScript syntax in a client block (it's plain JSX), pointing at your .pyxl source line. TypeScript.
  • New guides: TypeScript and Migrating from Flask or Django.
  • error.pyxl is now a client-side error boundary too — a render fault after hydration renders the nearest boundary instead of a blank screen, with the same error prop on both sides. Error Handling.
  • Built-in rate limiting — pyxle.middleware.RateLimitMiddleware. A dependency-free token-bucket limiter configured from pyxle.config.json; per-process, so rate-limit at the proxy for one global cap. Off by default. Middleware.
  • Route policies now apply to @action endpoints via routeMiddleware.actions, closing a bypass where an auth policy wrapped pages but not actions. Middleware.
  • pyxle serve --workers 0 auto-detects the core count. The Deployment guide adds a per-worker-state table and rolling-deploy guidance.
  • Build optimization: responsive <Image>, modulepreload hints, and --analyze. <Image> emits a responsive srcset via a loader and gains fill/sizes/priority; the SSR shell preloads entry chunks; pyxle build --analyze reports bundle sizes. Build Optimization.
  • Background tasks & deferred work. request.state.background.add_task(...) (or a {"background": [...]} return) runs after the response; pyxle.tasks.enqueue(...) schedules fire-and-forget work on an in-process pool (per-process — hand off to Celery/ARQ/Dramatiq for durability). Background Tasks.
  • OpenTelemetry tracing (opt-in). Request/SSR/loader/action spans via the [observability-otel] extra; fully off and zero-cost by default. Observability.
  • pyxle dev --dashboard — a live terminal observability panel (throughput, error rate, latency, cache hit ratio); dependency-free and dev-only. Observability.
  • Structured access logging — one line per request (method/path/status/duration_ms/correlation id) in console or JSON via observability.accessLog. Observability.
  • Observability: request IDs, timing, metrics, and richer health probes. Every request gets a correlation id (X-Request-Id) and timing; opt-in Prometheus metrics; /readyz runs dependency checks. Metrics are per-worker. Observability.
  • Typed @action request validation with Pydantic. Annotate a body: Model parameter and Pyxle validates before the action runs, returning 422 with a fields map on failure; pyxle openapi generates an OpenAPI 3.1 document from your models. Optional [pydantic] extra. Server Actions.
  • Fix: a server module that fails to import is no longer cached as a broken empty module — the real error re-raises on each request.
  • WebSockets — page handlers, a client hook, and pub/sub. A page can export async def websocket(ws); pyxle.realtime adds channel/room.publish, WS auth/origin helpers, and a useWebSocket() hook. The in-process broker is per-worker (a Redis broker drops in). WebSockets.
  • Streaming SSR for <Suspense> pages. The shell flushes immediately and each boundary streams in as it resolves; opt-in, zero-config, dynamic pages only. Streaming.
  • loading.pyxl route-level loading states wrap a route in <Suspense>, streamed as the shell and applied identically on the client. Streaming.
  • Server-side page caching with incremental regeneration. Return {"data", "revalidate": N} (or a CACHE directive) to cache rendered HTML; pyxle build --static warms it; stale-while-revalidate, strong ETag, and cache.invalidate(...). In-memory/disk/Redis backends. Caching.

0.4.5#

  • Signed cookies & tokens — sign_cookie / verify_cookie. Stdlib-only helpers that attach a tamper-proof HMAC-SHA256 signature to any string (session id, unsubscribe link, reset token) and verify it in constant time; a salt= namespaces signatures, and signing without a secret fails closed. Security.
  • Fix: PYXLE_PUBLIC_* client env vars now work in pyxle dev and no longer break pyxle build — values are emitted as quoted JS string literals, so a non-identifier value (API URL, Turnstile key) resolves in both. Environment variables.
  • Fix: web fonts and other CSS url() assets no longer 404 in pyxle dev — Pyxle sets Vite's server.origin so dev assets load from Vite directly.

0.4.4#

  • Fix: cross-page hash links scroll to their anchor. Client-side navigation to /page#section now jumps to the top and scrolls to the anchor once the next page commits, matching native behaviour.

0.4.3#

  • Security: hardened HEAD sanitisation (XSS). Dynamic HEAD values are now parsed and rebuilt from a strict tag allowlist with every attribute HTML-escaped, on* handlers dropped, dangerous URLs neutralised, and non-head tags rejected — closing an injection via the dynamic-meta-tags recipe. Inline <script>/<style> stays supported as trusted author code.
  • Security: csrf.exemptPaths match on segment boundaries, so exempting /api/webhooks no longer also exempts /api/webhooks-admin.
  • Security: oversized no-JS form POSTs fail loud with a 413 (asking for the token via header) instead of being silently truncated.
  • Fix: <Script>/<Image> boolean attributes written as strings (defer="false", priority="0") now coerce correctly instead of to True.
  • Fix: custom csrf.cookieName/headerName now reach the client runtime — non-default names are injected into the shell so useAction/<Form> stop 403-ing.
  • Fix: no double loader run on hover-then-click navigation — the click reuses the in-flight prefetch, and a superseded prefetch is discarded.
  • Multi-core serving: pyxle serve --workers N. N independent server processes on one port, each with its own SSR pool — throughput scales with cores, no load balancer or shared state. Deployment.
  • Sync API endpoints. A plain def endpoint(request) (and sync HTTPEndpoint methods) now runs in Starlette's threadpool, so blocking drivers no longer need manual asyncio.to_thread. API Routes.
  • In-memory static asset cache. Small static files (≤1 MB, 32 MB/process) are served from memory with no filesystem I/O; conditional requests and cache headers behave as before.

0.4.2#

  • Live dev-server reconciliation. Editing a .pyxl now applies route-shape changes without a restart — rename/add/remove a loader or action, add/delete a page, wrap a page in a layout — by hot-swapping the route table; editing pyxle.config.json prints a "restart to apply" warning.
  • pyxle check works on a clean install — the JSX checker's parser dependencies are bundled (via pyxle-langkit), so check runs after pip install 'pyxle-framework[langkit]' with no npm setup.
  • Locale-independent SSR — the Python↔Node transport pins UTF-8, so non-BMP characters no longer crash rendering under LANG=C.
  • Smoother first runpyxle init writes a gitignored .env.local with a random dev secret, the scaffold requirements.txt declares pyxle-framework, and pyxle install gives PEP 668 guidance.
  • Docs: documented calling an @action endpoint directly for scripts and tests.

0.4.1#

  • No more double loader run on first load. The landing page is seeded into the client navigation cache from the server render, so its prefetch resolves from cache — the loader runs once, not twice (also making back/forward instant).
  • Per-route navigation-cache TTL. A route's cache TTL now also governs client navigation-cache freshness; routes without one default to 2 minutes, tunable via navigation.defaultPrefetchTtl.

0.4.0#

  • Edge caching. Declare cacheable routes in pyxle.config.json::cache and pages serve Cache-Control: public, s-maxage=N (+ stale-while-revalidate) so a CDN absorbs traffic; the per-user CSRF cookie is omitted from cacheable responses. Deployment.
  • Hardened production errors. SSR render failures (including the SPA-navigation JSON path) are sanitized in the response and logged in full for operators.
  • Faster static serving. The static-asset middleware indexes paths up front, skipping a per-request stat on every dynamic request.
  • Layout & template loaders. A layout.pyxl/template.pyxl can declare its own @server loader, so shared UI loads once per request without repeating the loader in every page. Layouts.

0.3.0#

  • First-class plugin system. Compose apps via pyxle.config.json::plugins (Django-style INSTALLED_APPS). Plugins guide, Plugins API.
  • Django-style service access. Resolve any plugin service with plugin("auth.service") or a typed shortcut (e.g. from pyxle_auth import get_auth_service).
  • First-party plugins. pyxle-db (SQLite-first with migrations) and pyxle-auth (email+password sessions, argon2id, rate limits).
  • WebSocket endpointspages/api/*.py can export async def websocket(ws). API Routes.
  • Client navigation cache with TTL + invalidation. Loader payloads are cached (30s default) for instant back/forward; call invalidate(url) or return invalidate_routes(...) from an action to keep lists fresh.
  • ActionError is auto-imported for any .pyxl with an @action.
  • <Head> coerces multi-part <title> children into a single string, silencing React's array-title warning.
  • SSR worker pins LANG=en-US.UTF-8 (override with PYXLE_SSR_LOCALE) to stop Intl hydration mismatches.
  • Vite resolver prefers pinned versionspyxle build runs npm install before falling back to npx --yes vite.