The runtime
The "runtime" of Pyxle is the smallest module in the framework.
It contains exactly two decorators (@server and @action), two
exception classes (LoaderError and ActionError), and nothing
else. The whole file is 83 lines long including blank lines and
docstrings.
This is on purpose. The runtime is what your application code imports — it's the contract between your code and the framework. A small contract means a small surface area for bugs, a small mental model for users, and a small attack surface for prompt injection through documentation.
This doc is the shortest in the architecture section because there isn't much code to explain. But the design decisions behind the runtime are some of the most important in Pyxle, and they're worth understanding.
File: pyxle/runtime.py (83 lines)
What's in pyxle/runtime.py
The entire file is small enough to reproduce here:
"""Runtime helpers exposed to compiled Pyxle artifacts."""
from __future__ import annotations
from typing import Any, Callable, TypeVar
F = TypeVar("F", bound=Callable[..., Any])
def server(function: F) -> F:
"""Mark a function as a Pyxle loader and return it unchanged."""
setattr(function, "__pyxle_loader__", True)
return function
def action(function: F) -> F:
"""Mark a function as a Pyxle server action and return it unchanged."""
setattr(function, "__pyxle_action__", True)
return function
class ActionError(Exception):
"""Raise from within a @action function to return a structured error."""
def __init__(
self,
message: str,
status_code: int = 400,
data: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
self.data = data or {}
class LoaderError(Exception):
"""Raise from a @server loader to trigger the nearest error boundary."""
def __init__(
self,
message: str,
status_code: int = 500,
data: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
self.data = data or {}
__all__ = ["server", "action", "ActionError", "LoaderError"]That's it. That's the runtime. Two decorators, two exceptions, one
__all__.
Why so small?
Most web frameworks have a runtime that's enormous: a request context, a global state container, dependency injection, an event loop, middleware execution, response builders, error handlers, the works. Pyxle has none of those things in its runtime.
The reason is the compiler does all the framework work, not the runtime. By the time your code runs at request time:
- The parser has already extracted your loader and action metadata.
- The compiler has already injected the runtime imports into the artifact.
- The dev server has already imported the compiled module.
- Starlette has already dispatched the request.
- The SSR pipeline has already validated everything.
When the framework finally calls your @server-decorated function,
the function is just a function. There is no context manager
wrapping it, no try/except catching its exceptions, no proxy object
intercepting attribute access. It runs as if you'd called it
yourself.
This is the no-magic design principle from the architecture README, applied as literally as possible.
What @server actually does
Here's the entire implementation of @server:
def server(function: F) -> F:
setattr(function, "__pyxle_loader__", True)
return functionThat's the whole decorator. It sets one attribute on the function
and returns it. The function is the same function — same identity,
same signature, same __name__, same __doc__, same __module__,
same closure cells, same everything. You can call it directly
without going through Pyxle:
from pyxle.runtime import server
@server
async def load_home(request):
return {"hello": "world"}
# All of these still work:
print(load_home.__name__) # "load_home"
print(load_home.__pyxle_loader__) # True
import asyncio
asyncio.run(load_home(fake_request)) # {"hello": "world"}The framework's only use of __pyxle_loader__ is at parse time, in
the compiler — and even then, the compiler reads it from the AST
(by checking decorator names), not from the runtime. The attribute
is set as a documentation aid for tooling and debuggers, not as a
mechanism the framework relies on.
@action works identically, with __pyxle_action__ instead.
Why decorators at all then?
If @server doesn't do anything, why is it a decorator? Why not
just a naming convention like Flask's def hello():?
Because the parser uses the decorator name to find loaders and
actions in the AST. When the parser walks your .pyxl file, it
asks each function definition: "is one of your decorators called
server?" If yes, it treats the function as a loader. Same for
@action.
Naming conventions (def loader(request):) would work, but they
have downsides:
- Multiple functions per file would conflict. If the convention
is "the function named
loaderis the loader", you can't have helper functions namedloader_helperwithout confusion. With a decorator, you can name the function whatever you want. - You'd lose the explicit "this is a Pyxle thing" signal. When
you read someone else's code,
@serverimmediately tells you "this is the framework's entry point". A naming convention is invisible. - Refactoring tools can't track names as reliably as decorators. Renaming a function is easy; changing a magic name convention isn't.
The decorator wins on every axis except one: it requires an
explicit from pyxle.runtime import server, and even that is
auto-injected by the compiler so you don't actually have to write
the import yourself.
Pyxle in plain Python: You can write the import yourself. The compiler checks for an existing
from pyxle.runtime import serverand skips the auto-injection if you've already added it. The auto-injection exists because most users won't bother, not because the framework rejects explicit imports.
The exception types
LoaderError and ActionError are the only structured errors
the runtime defines. Both follow the same shape:
class XError(Exception):
def __init__(self, message, status_code=..., data=None):
super().__init__(message)
self.message = message # Human-readable message
self.status_code = status_code # HTTP status to return
self.data = data or {} # Additional structured payloadYou raise them from inside your loader or action when something is wrong:
@server
async def load_post(request):
slug = request.path_params["slug"]
post = await db.fetch_post(slug)
if post is None:
raise LoaderError("Post not found", status_code=404)
return {"post": post}When LoaderError is raised:
- The framework catches it.
- It looks for the nearest
error.pyxlboundary. - It renders the boundary with the error context as a prop:
{error: {message, statusCode, data}}. - It returns the response with HTTP status
404.
ActionError works the same way for @action functions, except it
returns a JSON error response ({"error": {"message": "...", "statusCode": 400, "data": {...}}}) instead of rendering an HTML
boundary.
The default status_code differs:
LoaderErrordefaults to 500 because most loader failures are server-side bugs.ActionErrordefaults to 400 because most action failures are client-side input problems.
You override the defaults with the keyword argument:
raise LoaderError("Forbidden", status_code=403)
raise ActionError("Email already taken", status_code=409, data={"field": "email"})Why two exception types?
Because they're caught at different points in the request pipeline, they translate to different response shapes (HTML page vs JSON), and they trigger different debugging affordances (error boundary vs client-side error handler).
A single PyxleError would force the framework to disambiguate at
catch time, and would force users to remember which fields apply to
which context. Two types make the contract explicit.
The zero-dependency rule
pyxle/runtime.py has zero imports from the rest of the
framework. The only things it imports are:
from __future__ import annotations(a Python 3.x compatibility thing)from typing import Any, Callable, TypeVar(standard library)
No pyxle.compiler, no pyxle.devserver, no pyxle.ssr, nothing.
This is enforced by pyxle/CLAUDE.md rule 5 (the module boundary
rule).
The reason is runtime.py is the only module that ends up imported by your application code. When you write:
from pyxle.runtime import server, action, LoaderError, ActionError…you're pulling runtime.py into your project's import graph. If
runtime.py imported pyxle.compiler.parser, your project would
transitively depend on the parser, the AST module, the JSX import
rewriter, the Babel subprocess wrapper, and so on. That's a huge
dependency footprint for a module that ends up being five lines of
metadata-setting code.
The zero-dependency rule keeps the runtime fast to import and easy
to test. You can import pyxle.runtime in 5 milliseconds. You can
test it without setting up a full Pyxle project. You can use it from
non-Pyxle code if you wanted to, just to mark functions for some
other framework's use.
How the framework actually finds your loader
Given that the runtime decorators don't do anything at runtime, how does the framework find your loader when a request comes in?
The answer: at parse time, not at runtime.
When the parser processes pages/index.pyxl, _detect_loader()
walks the Python AST looking for AsyncFunctionDef nodes whose
decorator_list contains a Name named server or an Attribute
ending in .server. The first match becomes the loader. Its name
is recorded in the LoaderDetails dataclass:
@dataclass(frozen=True)
class LoaderDetails:
name: str # e.g. "load_home"
line_number: int # for error mapping
is_async: bool # always True for loaders
parameters: Sequence[str] # ["request"]This metadata flows into the PageMetadata (compiled), then into
the .json artifact, then into the MetadataRegistry at startup,
then into the PageRoute dataclass.
At request time, the page handler does:
loader_fn = getattr(module, page.loader_name)
data = await loader_fn(request)It looks up the loader by name. The name was recorded by the parser at compile time. The decorator's role is naming the metadata key, not modifying behaviour.
This is why the runtime can be three lines per decorator: by the time the function runs, the framework already knows everything it needs to know about it. The decorator doesn't have to do work because the parser already did it.
What if I need *real* request middleware?
If you need to wrap loaders with cross-cutting behaviour (auth, logging, rate limiting, etc.), Pyxle has two options:
Custom middleware — Starlette ASGI middleware declared in
pyxle.config.json:{ "middleware": ["my_auth_module:AuthMiddleware"] }This is the right place for things that should run on every request regardless of which loader is invoked.
Route hooks — Per-route policies declared in
pyxle.config.json:{ "routeMiddleware": { "pages": ["my_module:require_login"] } }This is the right place for things that should run for most page or API routes but not all of them.
Both mechanisms wrap the request outside the loader, so the
loader itself stays untouched. The runtime decorators don't have to
participate. Source: devserver/middleware.py,
devserver/route_hooks.py.
What if I want to test my loader?
Because @server doesn't wrap the function, testing is trivial:
# tests/test_pages.py
import asyncio
from pages.index import load_home # The compiled file
class FakeRequest:
pass
def test_load_home():
result = asyncio.run(load_home(FakeRequest()))
assert result["hello"] == "world"You don't need a test client, you don't need a fake Starlette app, you don't need to mock the request context. The loader is a plain async function — call it.
In practice, you probably want to use Starlette's TestClient for
end-to-end tests, but you don't have to. Unit-testing individual
loaders is straightforward because they're just functions.
A note on async
Both @server and @action require the function to be async.
This isn't enforced by the runtime (the decorator runs at module
load time, before any function is called) — it's enforced by the
parser at compile time:
$ pyxle check
error: [python] line 5: @server loader must be declared as async
--> pages/sync_loader.pyxlThe reason is consistency: every Pyxle handler runs on the asyncio event loop, and a sync function would block the loop. Forcing async at compile time avoids the runtime gotcha where a sync function silently degrades performance.
If you need to call a sync library from inside an async loader, use
asyncio.to_thread():
@server
async def load_data(request):
rows = await asyncio.to_thread(blocking_query, "SELECT ...")
return {"rows": rows}This pattern is documented in core-concepts/data-loading.md.
Where to read next
The parser — How
_detect_loader()and_detect_actions()walk the AST to find decorated functions and validate their signatures.The compiler — How the runtime imports get auto-injected into compiled
.pyartifacts so users don't have to writefrom pyxle.runtime import serverthemselves.Server-side rendering — How loaders are actually invoked at request time, including the dev-mode
sys.modulespurge that makes hot reload work.