Full-stack apps¶
A Wybthon app can be one Python codebase for the browser and the server. The server renders every page per request, so it shows before Pyodide boots, and runs the app's server functions: async defs that the browser calls as if they were local. The router's queries read data through them, and its form actions change it, with forms that work before Python has started in the browser. This is Solid 2.0's model: "use server", query, and formAction, with Python on both ends.
A project¶
The starter has file routes, a query, and a form action. Its wybthon.toml turns the server on:
| Setting | Meaning |
|---|---|
server |
Render pages per request and serve server functions. wyb dev and wyb serve need it to do either, and a build that finds server functions without it fails. |
server-only |
Glob patterns of modules that never reach the browser, such as database code. |
middleware |
module:function names run around every request (see Middleware). |
routes |
The file routes directory, when it isn't <app-dir>/routes. |
wyb dev serves the app with pages rendered per request, rebuilds when a file changes, re-imports the server's modules, and updates the open page in place. wyb build writes the production build, and wyb serve serves it (see Serving).
Server functions¶
server_function makes an async def run on the server wherever it's called from:
from dataclasses import dataclass
from wybthon import server_function
@dataclass
class Todo:
id: int
title: str
done: bool = False
@server_function
async def complete(todo_id: int) -> Todo:
from app.server.db import todos # never imported in the browser
return await todos.complete(todo_id)
Call it like any coroutine: todo = await complete(3). On the server (while a page renders, or inside another server function), the call runs the function directly. In the browser, it posts the arguments to {base}_wyb/fn/app.todos:complete and returns the answer.
- What reaches the browser. The build replaces the body of every server function with
...in the browser's copy of the module, and removes each import only those bodies used, soapp.server.dbisn't imported (or shipped, beingserver-only) in the browser. Line numbers don't change, so tracebacks still point at the right lines. - Arguments and results cross in Wybthon's wire format: JSON, plus dataclasses, enums, tuples, sets,
datetime,date,time,timedelta,Decimal,UUID, andbytes. On the server, every argument is rebuilt and checked against the function's own annotations, socomplete("3")is a 400, not a string where anintshould be, and a client can't make the server build anything the function didn't declare. Annotate every parameter. - Errors. An exception the function raises arrives in the browser as
ServerError, with the exception's type name intype_name. In development its message comes along; in production only aServerErrorthe function raised itself carries its message, so internal details don't leak. RaiseServerError("Message for the user", "PermissionError")for errors meant for the page. - Where it's defined. A server function must be a module-level
async def, so the browser and the server name it the same way:module:qualname. - The request.
get_request_event()returns the request the call came in with. Readevent.headersandevent.cookies, useevent.localsfor what middleware set, and callset_cookieorhttp_headerto shape the response. - Security. A call from a script carries an
x-wyb-fnheader, which a page on another origin can't add without the server's permission. A form post, which can't carry a header, must come from the same origin (bySec-Fetch-SiteorOrigin). Authorization is still yours: check the user in every server function that needs it.
Mutations and reads¶
A server function is called with POST by default, and a POST is a mutation: when it completes, the browser revalidates every query on the page, so what's shown reflects the change. Declare a read with @server_function(method="GET"); a server function wrapped in query is a GET automatically.
Responses¶
Raise or return one of these to do more than return a value:
| Helper | Effect |
|---|---|
redirect(url, status=302, *, revalidate=None) |
The browser navigates to url. While a page renders on the server, it's the page's HTTP redirect. A JavaScript-free form post gets a 303 to it. |
reload(*, revalidate=None) |
Revalidate queries without navigating. |
respond(value, *, status=200, headers=None, revalidate=None) |
Return value with a status and headers. |
revalidate names the query keys (or key prefixes) a call changed; omit it to revalidate everything on the page, and pass "" to revalidate nothing:
@server_function
async def log_search(term: str) -> None:
await analytics.record(term)
return respond(revalidate="")
Queries¶
query wraps an async read so its calls are deduplicated and refetch when they're revalidated. Read one in an async memo, under a Loading boundary:
from wybthon import For, Loading, component, create_memo, html, server_function
from wybthon.router import query
@query
@server_function
async def list_todos() -> list[Todo]:
from app.server.db import todos
return await todos.all()
@component
def Todos():
todos = create_memo(lambda: list_todos())
def row(todo, _index):
return html(t"<li>{todo.title}</li>")
return Loading(html(t"<ul>{For(todos(), row)}</ul>"), fallback=html(t"<p>Loading...</p>"))
A query:
- dedupes calls on the server for the lifetime of a request, so components that read the same data don't fetch it twice;
- reuses a result in the browser for five seconds after it arrives, so a link's preload and the page it opens share one fetch;
- refetches, in every memo that read it, when
revalidatenames its key, or when a mutation completes. A memo that already shows data keeps showing it until the new data arrives.
A query's key defaults to its server function's id, or the function's module and name; pass one explicitly with @query("todos") or query(fn, "todos"). A call's full key is the query's key followed by its arguments, and revalidate matches by prefix: revalidate(get_user) (or revalidate(get_user.key)) refetches every user, and revalidate() refetches every query.
When the server renders a page, the values its queries resolved are sent with the page, so the browser hydrates with them instead of fetching again.
A query doesn't have to wrap a server function: wrap any async read, such as a call to an external API, to get the same deduplication and revalidation.
Form actions¶
form_action makes an action that a <form> posts to. Put it in the form's action attribute:
from wybthon import FormData, server_function
from wybthon.router import form_action
@form_action
@server_function
async def add_todo(form: FormData) -> None:
from app.server.db import todos
title = str(form.get("title", "")).strip()
if not title:
raise ServerError("A todo needs a title", "ValueError")
await todos.add(title)
@component
def NewTodo():
return html(t"""
<form action={add_todo} method="post">
<input name="title" required>
<button disabled={add_todo.pending()}>Add</button>
</form>
""")
- With Python running in the browser, the submit runs the action with the form's
FormData(text fields asstr, files asUploadedFile). It's an action:add_todo.pending()is true while it runs, optimistic writes made for it revert when it settles, and when it completes, the page's queries revalidate. The form hasaria-busy="true"meanwhile, for CSS. - Before Pyodide has booted, or without JavaScript, the browser posts the form to the server function's URL. The server runs the action and redirects back to the page (or to where the action redirected), and the submission's result rides along in a short-lived cookie, so
use_submissionsshows it on the page that loads. A first-time visitor can use the form while Python is still downloading. - Bound arguments.
action.bind(*args)passes arguments ahead of the form data, as Solid'saction.withdoes:<form action={rename.bind(todo.id)} method="post">callsrename(todo.id, form). They travel in the URL, so they're validated like any other argument. - Calling it directly. A form action is also callable:
await add_todo(form). - Hooks.
add_todo.on_submit(fn)callsfn(*input)when a submission starts, inside its transaction (write optimistic state there), andadd_todo.on_settled(fn)callsfn(submission)when one settles. Both belong to the calling owner and go away with it.
A form action that isn't a server function runs in the browser only, so it needs a stable name: form_action(save_draft, "save-draft").
Submissions¶
use_submissions(action) is a tracked accessor for the action's settled Submissions, oldest first: each has input (the arguments, ending with the FormData), result, error, and url, and clear() and retry() methods. It's history, not in-flight state: show in-flight state with action.pending() and optimistic primitives.
@component
def Errors():
submissions = use_submissions(add_todo)
def message():
failed = [item for item in submissions() if item.error is not None]
return str(failed[-1].error) if failed else ""
return html(t'<p role="alert">{message()}</p>')
File routes¶
file_routes("app.routes") turns a package of route modules into a route tree:
app/routes/
_layout.py the root layout (optional)
index.py /
about.py /about
users/
_layout.py a layout for everything under /users
index.py /users
[id].py /users/:id
[[tab]].py /users/:tab? (an optional parameter)
(marketing)/ a group: organizes files without adding a segment
pricing.py /pricing
[...404].py anything else
A page module defines a Page component and may define preload(args). A _layout.py defines a Layout component, which receives the matched page through its children parameter. Like any route component, a Page or Layout declares the route props it needs (params, search, data, children) as parameters. Other files starting with _ are private to the directory, and a module without a Page isn't a route. Pages load lazily, so a page's startup imports only what it shows.
from wybthon.router import create_router, file_routes
router = create_router(file_routes("app.routes"))
HTTP handlers¶
A route module may also define HTTP handlers, for API endpoints: async def GET(event), and likewise POST, PUT, PATCH, and DELETE. The ASGI app calls one for a request with that method, except a GET from a browser loading a page (one that accepts text/html) when the module also has a Page. The build removes the handlers from the browser's copy, as it does server functions.
# app/routes/api/todos/[id].py
from wybthon import respond
async def GET(event):
todo = await db.todos.get(int(event.locals["params"]["id"]))
return {"id": todo.id, "title": todo.title}
async def DELETE(event):
await db.todos.delete(int(event.locals["params"]["id"]))
return respond(status=204)
A handler receives the RequestEvent; event.request is the Request (method, path, query, headers, body, json(), form()), and event.locals["params"] holds the route's parameters. It returns a dict or list (sent as JSON), a str, bytes, None (204), a Response, or a response helper.
Serving¶
| Command | What it serves |
|---|---|
wyb dev |
The project in development mode, rebuilt and reloaded on change. |
wyb serve |
A production build (wyb serve --build builds first), with Wybthon's small HTTP server. |
WYBTHON_PROJECT=. uvicorn --factory wybthon.asgi:create_app |
The same app under any ASGI server, for production traffic. |
create_app(project) returns the ASGI application: it renders pages into the build's page shell, streaming each Loading boundary as its data arrives, and serves server functions, HTTP handlers, and the build's assets (content-hashed ones with a year of caching). Routes listed under prerender are served from their prerendered HTML.
Middleware¶
Middleware runs around every request. List module:function names under middleware in wybthon.toml; each one receives the RequestEvent and a call_next function, and returns a Response:
from wybthon.asgi import Response
async def authenticate(event, call_next):
session = event.cookies.get("session")
event.locals["user"] = await sessions.user_for(session) if session else None
if event.request.path.startswith("/admin") and event.locals["user"] is None:
return Response(b"", 303, {"location": "/login"})
response = await call_next()
response.headers["x-frame-options"] = "DENY"
return response
Components and server functions read event.locals through get_request_event().
Testing¶
wybthon.testing covers both sides:
connect_server()makes the components a test renders call their server functions the way the browser does, with arguments encoded, validated, and decoded, in-process.Client(app)sends requests to an ASGI app without a network: pages, form posts (client.post_form(...)), and HTTP handlers, keeping cookies between requests.
from wybthon.asgi import create_app
from wybthon.testing import Client, connect_server, fire, render, tick
async def test_adding_a_todo():
with connect_server(), render(TodoPage()) as screen:
fire.input(screen.get_by_role("textbox"), "Write tests")
fire.submit(screen.get_by_role("form"))
await tick(4)
assert screen.get_by_text("Write tests")
def test_the_form_works_without_javascript():
client = Client(create_app("."))
response = client.post_form("/_wyb/fn/app.todos:add_todo", {"title": "Write tests"})
assert response.status == 303
assert "Write tests" in client.get("/").text
See also¶
- Router: routes, links, and preloading
- Server rendering: streaming, hydration, and the response head
- Deployment
- RFC 0008