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 toinlineStylesheetLimitbytes 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: configurableCache-Controllifetime for un-hashedpublic/files in production — the previous fixed one hour is now the default; hashed client bundles stayimmutablefor a year, and development keepsno-cache.Module-preload hints are now
fetchpriority="low", andassets.modulePreload: falsecan 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
.pyxlexample is now onepyxlcode block instead of apythonfence stacked on ajsxfence — one file in the docs the way it is one file on disk.
0.9.3#
Fix: the root
vite.config.jsnow loads after a build too. 0.9.2 stopped a freshly scaffolded project's root config from throwingERR_MODULE_NOT_FOUNDbefore the first build, and it did — but it left a second failure in its place. The generated config it defers to setsconst clientRoot = __dirname, and a scaffolded project'spackage.jsondeclares"type": "module", so that identifier does not exist there. Nothing caught it because Vite injects__dirnamewhen it bundles the config it is handed directly, which is exactly howpyxle devandpyxle buildload it; the root config instead reaches the generated one through a runtime dynamicimport(), 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 scopeon 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 ownloadConfigFromFile: 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'sroot, which is the client directory Pyxle generates, so point it at your own tests withvitest run --root .; the Testing guide explains why. (shadcnis unaffected by this bug — it does not read the Vite config at all.)clientRootis derived fromimport.meta.url, and a test asserts the generated config contains no__dirnamein code.Fix:
shadcn/uicomponents 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 owncomponents/— which is exactly whereshadcn/uiputs every component it installs — was never generated, and the component rendered unstyled, with no error anywhere:npx shadcn@latest add buttonreported 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 yourjsconfig.jsondeclares (pagesexcluded — it is already under the Vite root). Verified in a browser on a scaffolded shadcn project: a class present only incomponents/ui/button.jsxnow generates, inpyxle devand 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
debugflag, not a hardcoded one.build_api_routerpasseddebug=Trueunconditionally, 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 editedpages/api/*.pyon the next request, and a productionpyxle servestill answers its API routes.Fix:
/llms.txtlinks stayhttps://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 ofhttp://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 honoursX-Forwarded-Protoonly from peers inforwarded_allow_ips(127.0.0.1by 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 outhttps://, from a non-loopback peer the identical request producedhttp://. Pyxle now reads the header itself. The rule lives in one place and the CSRF cookie'sSecuredecision uses the same function, so the two surfaces cannot drift into disagreeing about whether the client used TLS. A proxy that reportshttpis still taken at its word — the scheme is never guessed upwards.Fix: a freshly scaffolded project's root
vite.config.jsno longer throws. The scaffold writes one soshadcn/uiframework 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 firstpyxle devorpyxle build. So on a brand-new project the file existed and raisedERR_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. ASameSite=Nonecookie must also beSecure— the specification requires the pair, and every current browser rejects the cookie without it. Pyxle deliberately withholdsSecureover plain HTTP (aSecurecookie is dropped there, which would break the form and protect nothing), and that reasoning was applied toSameSite=Nonetoo — 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@actionfailed its CSRF check, with nothing in the terminal or the console to say why. The header is now spec-correct —SameSite=None; Securealways 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 routessample 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 aspages/index.pyxlwhere the command prints them relative topages/— contradicting the quick-start's own sentence saying so. Both samples are now the command's real output, captured by running it, and theroutestest 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 — anendpointfunction branching onrequest.method— tested== "GET"and fell through to its own405for anything else, so a route copied from our own documentation answered 405 Method Not Allowed tocurl -I, uptime monitors, health probers and link checkers. The framework was not at fault and is unchanged: it deliberately letsHEADthrough to your handler wherever the route acceptsGET, and the class-basedHTTPEndpointalternative in the same guide got this right, which is what made the gap easy to miss. The example now handlesGETandHEADtogether and says why, and the note onenforce_allowed_methodsstates the rule. If you copied the old shape, widen that first branch toif request.method in ("GET", "HEAD"). API routes.Fix: a client-side navigation no longer calls your
LoaderErrora server error. An author-raisedLoaderErrororActionErroris 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 withServerError, 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, itsdata, and its status all arrive intact. Nothing loosened for anything else: a non-author exception is still replaced with a generic string andServerErrorin production, with the exception's own class name withheld. Error handling.Fix: every module imports on its own again.
import pyxle.ssr.viewandimport pyxle.ssr.templateraisedImportError: ... most likely due to a circular importfrom a cold interpreter.pyxle/devserver/__init__.pyimportedstarlette_appat module scope,starlette_appimportspyxle.ssr, andpyxle.ssrimports back intopyxle.devserverfordev_originsanderror_pages— so entering the cycle from the SSR side found a half-built module. It never affected a running app, becauseimport pyxleand 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 devhad 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 servehad already rebuilt the whole project, and had already printedServing 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, beforenpm 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 serveon 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.
LoaderErrorandActionErrortake astatus_codeprecisely so your code can decline a request — a missing post is a404, a signed-out visitor a401— 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 theraise, and the Vite client tag that reloads the page when you fix it. Error handling.
0.9.1#
Fix:
STANDALONEon atemplate.pyxlnow stops the wrapper chain, not just the head and loader chains.STANDALONE = Truemakes 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 alayout.pyxlall three stopped. On atemplate.pyxlonly two did — the wrapper walk consultedlayout.jsonalone and never looked attemplate.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, declaringSTANDALONEends the chain above it. Layouts.Fix:
pyxle checkno longer passes an action that can never run. An@actionwhose 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 openapialready refused such a file and said exactly why, whilepyxle checkon the identical tree reported "All checks passed" and exited0. 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 messageopenapigives, naming the action, the parameter and the line.checkstays 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 inpyxle.runtimebeside@actionitself, so the gate and the dispatcher cannot drift into describing the same mistake two ways.pyxle check.
0.9.0#
Docs: why the
log.infothat worked all through development is silent once you deploy.pyxle devinstalls a logging bridge that lowers the root logger toINFOso your records reach the terminal and the browser console.pyxle serveinstalls nothing — the process is yours and Python's defaults apply, soINFOis dropped whileWARNINGand 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-linebasicConfigthat fixes it, and rules out the two things developers reach for first: the observabilityaccessLogoption configures thepyxle.accesslogger 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
.pyxlwith a syntax error never compiles, sopyxle devnever registers a route for it, so requesting its URL fell through to the 404 — which advised checking that the file is inpages/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 ownnot-found.pyxlmade the wrong answer look designed. In dev, a URL a broken source would have served now answers500with 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 brokenpages/posts/[slug].pyxlanswers/posts/hello— matched with Starlette's own path compiler, and only where no route matched at all, so an endpoint raisingHTTPException(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, andpyxle buildstill refuses to produce adist/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 3got 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 andunterminated string literal (detected at line N)messages, andpyxle check'sredefinition of unused 'os' from line N,import 'os' from line N shadowed by loop variableandlocal variable 'v' defined in enclosing scope on line N. All of them are now translated to.pyxllines, the same way the position already was — in the terminal, inpyxle 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 asundefined name 'ghost on line 999' in __all__, with your own string intact. Error handling,pyxle check.Fix: a failed
pyxle buildnames 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 readspages/about.pyxl:13:8. A plain.jsxcomponent 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.jsxis 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.pyxlof the same name or from a.jsxcomponent 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
.jsxinside 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 insidehttp://localhost:5176/pages/index.jsx:3:9began 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.jsxinside 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, sohttps,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 serveno longer publishes the sources your bundle was built from. The client static mount was rooted one directory too high, atdist/client/instead ofdist/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.htmland/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 --analyzewalks 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.pyxlit produced complete silence in both places. A compiled page runs under a private module name that begins withpyxle., and the filter that keepsuvicorn,watchfilesand 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 toINFO, but the stderr fallback beside it stayed atWARNING, so a plainlog.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
esbuildas 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=devproduced 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, addesbuildtodependencies.The scaffold imports its global stylesheet in
pages/layout.pyxlrather than inpages/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 apublic/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 whatnpm cirequires — 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/chartsrenders a Recharts chart from a dict a@serverloader returned — one.pyxlfile,npm install recharts, no API route orfetchin 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 theResponsiveContainerreplacement 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 stableidfixes it. The example ships a realpublic/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 andintervalpicks which: a number means "take these ticks as they are", anything else — including thepreserveEnddefault 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, asy="12"againsty={16.796875}— the8and the12.796875Recharts computed, each plus the example's ownoffsetY={4}. That12.796875is half of25.59375, which is not the tick's own box: Recharts sizes a label by appending a hidden<span>todocument.bodyand measuring that, and on the render that hydrates the axis has no font size to give it yet, so it inherits the page's16px/1.6instead of the tick'sfont-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 carryinterval={0}, and Third-party packages says to give every axis a numericinterval, 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 devwith the console open, filtered forhydrat— 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 axiswidthinto one<tspan>per line — two for a two-word label — where the server emits exactly one, not by resolvingemoffsets. The example's README also told you to verify withpyxle build && pyxle serve, which cannot start —pyxle serverequiresPYXLE_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 9where the author'sraiseis atpages/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.pyxlalready 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.pyxlorigin in production too. A dist-only deploy is unchanged, and that is what makes this safe rather than merely better — when no.pyxlis 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 initrendered 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@serverloader 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 withuseState, 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 productionpyxle 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 definedand 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 namedpyxle.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, aspages/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 owndocs/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/chartsand 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 givesA 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 givesHydration failed because the server rendered text didn't match the client.; and an element one side emits and the other does not givesHydration failed because the server rendered HTML didn't match the client.—textandHTMLare 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. Thehydratconsole 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 — theinterval={0}fix on both<YAxis>holds.Fix: deleting a page no longer breaks the next build. Remove a
.pyxlyou no longer want, runpyxle 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 definedand 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 onlyerror.messageacross 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.pyxland line. SSR pipeline.Fix: an unclosed bracket names the bracket, not your indentation. A
(,[or{left open inside a function body was reported asunexpected indenton the line below it — a sentence about whitespace, when the whitespace was fine. The cause is where a.pyxlfile 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 closedis 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 withTypeError: 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 onlyrequestand read the body withawait 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.0serves a page that actually hydrates off this machine.pyxle devruns 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 generatedvite.config.jssethost,port,originandfsbut nevercorsorallowedHosts. So opening theNetwork: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.0still emittedlocalhost, the config'sviteblock accepted only host and port, and the project's own rootvite.config.jsis 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 notcors: 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
pyxlecommand 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 onlypyxle devwas ever told which addresses the server is on. So running one of them beside apyxle dev --host 0.0.0.0rewrote 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 answered200and a refused module is not a JavaScript error.pyxle devnow 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__/overlayrefused every origin that was not loopback, including theNetwork:URLpyxle dev --host 0.0.0.0prints 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 withrevalidateis stored once and served to many people, so Pyxle suppresses the per-user CSRF token — and<Form>renders without its hidden_csrf_tokenfield 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 thex-csrf-tokenheader); 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 devkept the previous pass' compiled output when a rebuild failed, so a page with a syntax error carried on answering200with 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 brokenlayout.pyxltakes 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.pyxlsource 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 buildis 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 readsRebuild 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.pyxlbroken, edits topages/index.pyxlnever 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 devstarts 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.pyunder adev.watchdirectory — 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 asReloaded pages/api/_shared.py in 9 msand 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, andpyxle.config.jsongained an optional top-level"name"for it (pyxle initwrites yours). With nonameset, 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/plainbody, 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. Underpyxle devit also namespages/not-found.pyxlas 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 leftoverimport jsonfailed 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 exit0. 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 routesno longer lists special files under URLs they don't serve.error.pyxlwas printed as/error, inviting you to visit an address that always 404s.error.pyxl,not-found.pyxlandloading.pyxlare 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 installprints its own output, not pip's. A successful install dumped dozens ofRequirement already satisfiedlines 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, andpyxle -v installstreams it live. CLI.pyxle initchecks 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-appno longer creates a directory calledapps-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
@actionis no longer discarded when the action refuses. A task registered withrequest.state.background.add_task(...)was attached only to a successful response, so an action that scheduled work and then raisedActionErrorlost it — the client got its4xx, 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_taskis a statement that executed, and a laterraiseno 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, andpyxle.tasks.enqueuealready 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]andid,[[...slug]]and[slug],(marketing)andmarketing,my-pageandmy_page,embed.jsandembed_jseach 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, at200, 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.pyxlandmarketing/pricing.pyxlare 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 openapiandpyxle routes --jsonno 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.jsonon a project that could not produce a schema therefore wrote the error message intoopenapi.jsonand 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 — sopyxle openapi | jqworks without filtering.pyxle routeswithout--jsonprints a table for a person to read and keeps stdout as before. CLI.Fix:
pyxle openapino 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@actionwhatsoever — 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 emptypathsobject 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.pyxlnow catches an ordinary exception from a loader, not only aLoaderError. The error boundary was reached for four framework-recognised failures — an author-raisedLoaderError, a loader Pyxle could not run or whose return value it rejected, a badHEAD, 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, aNonewhere 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 theirerror.pyxlnaturally raisesLoaderError, sees their page, and ships. An exception escaping a loader is now classified where it happens, as the loader-stage failure it is: the nearesterror.pyxlrenders with status500, 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.pyxlfile. The same applies to a loader on alayout.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-raisedLoaderErrorstill 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
500that renders througherror.pyxlis 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
@actionthat 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 JSON500itself, so it never reached the handler that does the logging, and only one narrow sub-case (an unsetrequest.stateattribute) 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. Every500the action dispatcher can return is now recorded before the response is built — a raised exception, a module that will not import, an unsetrequest.stateattribute, an action returning something other than a dict — once each, with the traceback and the name of the action that failed.ActionErrorstays 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.pyxlboundary took that file'sHEADvariable 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-evaluatedHEADvariable, the boundary's ownHEAD, and the<Head>elements its render produced — with the usual precedence, so an error page's<title>still beats the layout's. AHEADthat 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 buildno longer reports success when it could not build your client bundle. A build that cannot reachnpx— a machine with Node.js but no npm, the shapeapt install nodejsleaves behind, and the slim CI images and Docker bases built the same way — logged a one-line warning, skipped Vite, and exited0under a green "Build completed" banner. Thedist/it left held no browser JavaScript and named each page by its dev-server path, whichpyxle serverefuses 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 nopackage.jsonis refused the same way. Nothing is written todist/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 rulepyxle serveapplies 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 — theX-CSRF-Tokenheader, the_csrf_tokenform field (urlencoded or multipart), the CSRF cookie, apyxle.verify_cookieargument, and the metrics endpoint'sAuthorizationheader — 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 answer500to that browser until it was cleared. All of these are now ordinary mismatches —403for a bad CSRF token,401for a bad bearer token,Nonefromverify_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 noPYXLE_SECRET_KEYconfigured — 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.maxAgerejectstrueandfalse.boolis a subclass ofintin 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 servereads the build it serves. Routing metadata came from the intermediate.pyxle-build/cache while the artifacts came fromdist/, so the two could disagree — and a page'sclient_pathis 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 interruptedpyxle 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@serverloader 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 onlydist/had the sharper version of the same bug: no.pyxle-build/to read, so every route404ed.dist/now carriesmeta.jsonand is self-contained, andpyxle servere-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 underpages/api/_internal/) is deliberately not a route, so nothing compiled it intodist/server, while the endpoint whose first line isfrom pages.api._shared import …was compiled and shipped. Deploydist/on its own — a DockerCOPY --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-directorypages/**/llms.pyhandlers and colocatedpages/**/*.md, and a configuredstyling.globalStylesentry aborted startup outright, since its source is read and inlined into every rendered document.pyxle buildnow copies those files intodist/appandpyxle servefalls 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 outsidepages/— a project-rootdb.py, amiddleware.pynamed in your config — remains yours to ship. Deployment.Fix: an intentional
404no longer renders as "Server Error". A loader raisingLoaderError(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: a4xxsays 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,429and451each have their own; any other4xxsays the request was not accepted;5xxkeeps 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.pyxlsource 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 readhref="{faviconUrl}"as a relative URL and requested it, one failed round trip per such tag per page view, whileog:imageunfurled as a broken picture in every chat client that read it. Deduplication could not suppress it, because a link's key is itsrelplushrefandhref="{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: acontent="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
HEADvariable 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 PythonHEADvariable, 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 rootlayout.pyxl'sHEADwas therefore dropped from every page beneath it: site-wide JSON-LD, a critical-CSS<style>. Nothing restored it, because aHEADvariable 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 ownHEADvariable 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.jsxPyxle 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 readspages/about.pyxl:26:8and 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 thinksabout.jsxis 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 underpyxle 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 nomediaoronloadswap, 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 devis 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
HEADis evaluated, not just scanned for literals. Alayout.pyxlreached the document only when itsHEADwas 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, ajson.dumps(...)call, adef HEAD(data)callable — produced nothing, with no warning, no log line, no build error and nopyxle checkcomplaint, while the identicalHEADone 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 withjson.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'sHEADis 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 anerror.pyxl, and pages pre-rendered bypyxle build --static. AHEADthat 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
HEADentry 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 checkreports 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 againstwindow.location.origin, so clicking it navigated to/?days=7instead 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
Securewhen the request actually arrived over HTTPS. Production marked it unconditionally, and a browser discards aSecurecookie sent over plain HTTP — so every plain-HTTP production server (a LAN demo, an evaluation box, anything behind a proxy that omitsX-Forwarded-Proto) rejected its own sign-in form with "CSRF token missing" and no way to diagnose it. HTTPS deployments are unchanged,X-Forwarded-Protois 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:
HEADis now allowed on any API route that allowsGET. 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 answered405— so a route advertised asGETrefused everycurl -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
@actioncan set a cookie.request.state.cookies.set(...)/.delete(...)record a Starletteset_cookie/delete_cookiecall, applied to the response the action's returned dict becomes — including on anActionError, 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 withclientis served instead of returning404. The framework serves its build output under/client/, and the check that reserves that namespace compared leading characters rather than whole path segments — sopublic/client-logo.svg,public/clients.json, anything namedclient…, was mistaken for a bundle and never looked for inpublic/at all. Underpyxle devevery such file404ed. 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/__pyxlepaths.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.jssuffix alone, which answered with the app's index HTML. The endpoint worked underpyxle serveand silently returned a web page underpyxle dev. The app's own routes now win; genuine assets, including everything under/@vite, are unaffected.apidirectories may now sit at any depth underpages/. A.pyfile is an endpoint whenever the URL it maps to contains anapisegment, sopages/s/[slug]/api/v2/summary.json.pyserves/s/{slug}/api/v2/summary.json. Previously only a top-levelpages/api/counted, which left a per-tenant compatibility API — a path shape another vendor's clients already expect — with nowhere to live..pyfiles outside anapidirectory are still ignored by routing, so helpers stay safe to colocate. Note that a nested directory namedapinow publishes its.pyfiles 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 aGETat 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.mdrenditions the AI accessibility feature serves: a link to anapipath keeps pointing at the endpoint. Anapidirectory holds no pages, either: a.pyxlfile in one is now refused when the project is scanned — bypyxle devandpyxle buildalike — with an error naming the file and telling you to rename the directory. It used to publish as a page while theChart.jsxbeside 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 mentionedapi; the directory was reserved enough to break your components, not reserved enough to stop you putting a page there. Only directories count, sopages/api.pyxlis still an ordinary page, and a page URL containingapiis still yours to serve —pages/docs/[...slug].pyxlanswers/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
.pyfile whose name starts with an underscore is no longer an API route. Every.pyfile in anapidirectory was registered as an endpoint, and an API module that exports noendpoint, nowebsocketand noHTTPEndpointsubclass is a hard startup error — so puttingpages/api/_shared.pynext to the endpoints that import it, or letting a tool drop an__init__.pythere, 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__.pyand everything under_internal/serve no URL and stay importable by the endpoints beside them. Only the segments at or below theapidirectory are read this way —pages/_admin/api/health.pystill serves/_admin/api/health— and.pyxlpages are unaffected. API routes.STANDALONE = Trueon 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 devno 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 whoseon_startupraised 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 devreached 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 forWARNINGand above, exactly as Python does without it.Fix: editing a
@serverloader 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.pyxlfile 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 untilpyxle devwas restarted. What made it easy to hit and hard to place is that it is the first edit the Quick Start asks for (change themessagea 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
.pyxlfile. 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.pyxlfile 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 builddoes not use. It documented a four-step search for a Vite executable — a local install, thennpm install, then a global binary, thennpx— plus a dedicated error type and streamed[vite]output that no build has ever produced. Whatpyxle buildactually runs isnpx vite build, andnpxresolves the project's ownnode_modulescopy 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-browserpyxle check. Dev-only by construction, with aHost-header allowlist. Pyxle Studio. - New
pyxle studiocommand. 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
.pyxlfiles. Set a breakpoint on a line inside a@serverloader and on a line inside the JSX below it — both bind. Compiled server modules are remapped to their.pyxlsources, 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
pyxledebug 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 withcode --install-extension pyxle.pyxle-language-tools. Debugging. pyxle dev --inspectfor attach-style debugging. Hosts a debugpy server bound to127.0.0.1so 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
.pyxlsources. A loader/action error names your.pyxlfile and line instead of the compiled module under.pyxle-build/. Debugging. - Breaking:
/__pyxleis now a reserved URL namespace. The dev server's Vite asset proxy never forwards paths under it, so an app route under/__pyxleno longer resolves inpyxle dev(it still serves in production, where the namespace is unused). Move any such route before upgrading. Pyxle Studio. - Fixed on Windows:
NODE_PATHis built with the platform delimiter. A project that already setNODE_PATHgot 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 devnow persists module-level state across requests, likepyxle serve. A@server/@actionmodule 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 initnow requires an explicit target. A barepyxle initused to silently scaffold into the current directory; it now errors, pointing topyxle init my-app(new directory) orpyxle init .(current directory).
0.7.4#
- Fix:
pyxle devno longer hot-reloads in an endless loop on Linux. After a save, the rebuild's own file reads surfaced throughinotifyas events the watcher mistook for edits, re-triggering the rebuild forever (macOSFSEventsdoesn'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), neverres.data.title(a successful result has no.data;useAction().datais a separate hook property). Clarified across the Server Actions guide, the client API reference, and the scaffoldAGENTS.md.
0.7.2#
- Fix:
pyxle initscaffolds an installablerequirements.txtagain. The template'sstarlettepin conflicted with the framework's after 0.7.1's security bump; both now usestarlette>=1.3.1,<2.0. - Config: a boolean
portis now rejected instead of binding port 1.boolbeing anintsubclass slipped past the validator; it now raises a clearConfigError.
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 WindowsStaticFilesSSRF (CVE-2026-48818). Pyxle's own API is unchanged; a newpip-auditCI job guards dependencies. pyxle serverefuses to start in production withoutPYXLE_SECRET_KEY— previously only a warning, leaving CSRF tokens and signed cookies forgeable. Security, Deployment.pyxle dev/buildcheck 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.testinghelpers 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.mdalongside the AI-orientedAGENTS.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.pyxlrenders even when an ancestor layout has a@serverloader. 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_CONCURRENCYreaches the worker again,SIGTERMtopyxle devtears 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
exportsmap (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 initandnpx shadcn@latest add …works with noshadcn 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), andpyxle serveauto-sizes its worker pool. Streaming. pyxle initrenders the framework pin from the running version instead of a stale>=0.4.1that downgraded fresh projects.pyxle typecheckfails fast when TypeScript isn't installed, with an actionable message instead of npm's placeholdertsc. TypeScript.- Scaffolded
AGENTS.mdcorrected —LoaderError(likeserver/action/ActionError/…) is compiler-injected, not imported. pyxle checkworks out of the box —pyxle-langkitis 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_tokenfield. 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 devoutput is clean by default,--verbosefor 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 withdev.ignore. A shared module outsidepages/can now trigger hot reload;dev.ignoreis additive to the built-in ignores. Configuration → Development.
Documentation#
- Fixed the flagship WebSockets example — it derived the socket path from
windowduring SSR and 500'd; it now builds the path from loader data. - More doc fixes — a broken
@actionexample in the pyxle-db docs (await request.json()), documented CSRF on the pyxle-auth endpoints and where pyxle-auth accounts live, an honestpyxle checkscope note, and prerequisites up front in the Introduction. Accept: text/markdownnegotiation now follows RFC 9110 (q-values honoured, exact type match); newmarkdown_is_acceptable(accept)helper. AI accessibility./llms.txtnow emits absolute URLs and links.mdonly where Markdown actually resolves. AI accessibility.- Breaking: converted Markdown rewrites internal links to
.md—html_to_markdown()rewrites by default (rewrite_links=Falsefor the old behavior). AI accessibility. - Fix: a burst of rapid saves can no longer kill the dev server — builds are serialized,
meta.jsonwrites atomically, and the Vite subprocess is supervised with bounded backoff. - Fix: reading an unprovided
request.state.<name>(e.g.request.state.dbwithout pyxle-db) now gives a structured, guided error instead of a bareAttributeError. - Actionable SSR error when a component touches a browser global (
window,document, …) — dev names your.pyxlfile 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 checkgained a semantic layer — pyflakes over the Python section flags undefined names, unused imports, and redefinitions (compiler-injected names are recognized).- Duplicate
export defaultis 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.
LoaderErrorandinvalidate_routesare now auto-injected, matchingActionError/ValidationActionError. Runtime API.pyxle install --break-system-packagesfor externally-managed (PEP 668) environments. CLI.
0.6.0#
- AI accessibility — serve your app as Markdown, plus
llms.txt. Opt in with"llms": trueand every page gains a.mdrendition (andAccept: text/markdownsupport), an/llms.txtindex, and discovery headers; Markdown resolves from a co-located<page>.md, ato_markdownhandler, or an ancestorllms.py, with anautoConvertHTML→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
StreamingGZipMiddlewareflushes 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 --staticpre-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.pyxlno longer leaks internal error details in production — the boundary gets a generic message and sanitized type; author-raisedLoaderError/ActionErrormessages 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
.gitignoreno longer ignores.env, matching the env-vars doc. - Fix:
<Image>emits a lowercasefetchpriorityattribute (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.jsondrops the deprecatedbaseUrlin favor of tsconfig-relativepaths. - Fix:
pyxle typecheckworks on current TypeScript —"bundler"resolution, nobaseUrl. - Startup warning when a
BaseHTTPMiddlewareis 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
.pyxlsource line. TypeScript. - New guides: TypeScript and Migrating from Flask or Django.
error.pyxlis now a client-side error boundary too — a render fault after hydration renders the nearest boundary instead of a blank screen, with the sameerrorprop on both sides. Error Handling.- Built-in rate limiting —
pyxle.middleware.RateLimitMiddleware. A dependency-free token-bucket limiter configured frompyxle.config.json; per-process, so rate-limit at the proxy for one global cap. Off by default. Middleware. - Route policies now apply to
@actionendpoints viarouteMiddleware.actions, closing a bypass where an auth policy wrapped pages but not actions. Middleware. pyxle serve --workers 0auto-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 responsivesrcsetvia aloaderand gainsfill/sizes/priority; the SSR shell preloads entry chunks;pyxle build --analyzereports 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 viaobservability.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;/readyzruns dependency checks. Metrics are per-worker. Observability. - Typed
@actionrequest validation with Pydantic. Annotate abody: Modelparameter and Pyxle validates before the action runs, returning422with afieldsmap on failure;pyxle openapigenerates 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.realtimeaddschannel/room.publish, WS auth/origin helpers, and auseWebSocket()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.pyxlroute-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 aCACHEdirective) to cache rendered HTML;pyxle build --staticwarms it; stale-while-revalidate, strongETag, andcache.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; asalt=namespaces signatures, and signing without a secret fails closed. Security. - Fix:
PYXLE_PUBLIC_*client env vars now work inpyxle devand no longer breakpyxle 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 inpyxle dev— Pyxle sets Vite'sserver.originso dev assets load from Vite directly.
0.4.4#
- Fix: cross-page hash links scroll to their anchor. Client-side navigation to
/page#sectionnow 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
HEADvalues 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.exemptPathsmatch on segment boundaries, so exempting/api/webhooksno 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 toTrue. - Fix: custom
csrf.cookieName/headerNamenow reach the client runtime — non-default names are injected into the shell souseAction/<Form>stop403-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 syncHTTPEndpointmethods) now runs in Starlette's threadpool, so blocking drivers no longer need manualasyncio.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
.pyxlnow 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; editingpyxle.config.jsonprints a "restart to apply" warning. pyxle checkworks on a clean install — the JSX checker's parser dependencies are bundled (viapyxle-langkit), socheckruns afterpip 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 run —
pyxle initwrites a gitignored.env.localwith a random dev secret, the scaffoldrequirements.txtdeclarespyxle-framework, andpyxle installgives PEP 668 guidance. - Docs: documented calling an
@actionendpoint 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
cacheTTL now also governs client navigation-cache freshness; routes without one default to 2 minutes, tunable vianavigation.defaultPrefetchTtl.
0.4.0#
- Edge caching. Declare cacheable routes in
pyxle.config.json::cacheand pages serveCache-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
staton every dynamic request. - Layout & template loaders. A
layout.pyxl/template.pyxlcan declare its own@serverloader, 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-styleINSTALLED_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) andpyxle-auth(email+password sessions, argon2id, rate limits). - WebSocket endpoints —
pages/api/*.pycan exportasync 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 returninvalidate_routes(...)from an action to keep lists fresh. ActionErroris auto-imported for any.pyxlwith 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 withPYXLE_SSR_LOCALE) to stopIntlhydration mismatches. - Vite resolver prefers pinned versions —
pyxle buildrunsnpm installbefore falling back tonpx --yes vite.