HTTP Requests¶
UI events say what the host observed (a key, a click, a tick). Request hooks say what arrived over HTTP.
They live in xnano.requests: method
decorators (@on_get_request, @on_post_request, …), a generic request(...)
helper, and the Request / Response types. Handlers are methods on a
BaseGrid. Routes are collected when that grid is served — under
Web, or via
RequestServer.
A plain Terminal session does not open HTTP ports. Request hooks on a grid do nothing until a server that understands them is running.
Experimental
HTTP request hooks (xnano.requests) and the request-oriented server path
are experimental. The API may still change.
Serving HTTP¶
| Host / entry | Opens a port? | Serves request hooks? |
|---|---|---|
Terminal / Terminal.run(...) |
No | No — TUI only |
Web(state=...).run(..., host=..., port=...) |
Yes | Yes — cell UI + declared routes |
RequestServer / start_request_server / serve_requests |
Yes | Yes — HTTP only, no browser shell |
Web binds host / port on run(). Terminal never takes those arguments.
Examples below assume a normal process that can listen on a socket — they are
not Pyodide demos.
Declaring routes on a grid¶
Decorate a grid method with the HTTP verb and path. Paths are normalized to a
leading slash ("status" → "/status"; empty → "/").
All method decorators share the same call shapes:
Return a Response, a non-None value (wrapped as Response(body=str(...))),
or None (empty successful response when a route matched). Mutate grid fields
or application state the same way UI hooks do; the next paint reflects it under
Web.
GET requests¶
from xnano import BaseGrid, Field, Web
from xnano.requests import Response, on_get_request
class Status(BaseGrid):
body: str = Field(default="ok")
@on_get_request("/status")
def status(self) -> Response:
return Response.json({"ok": True, "body": self.body})
Web().run(Status(), port=8000)
HEAD requests¶
HEAD uses the same path matching as GET. Handlers still run; servers that
speak the full method set omit the response body on the wire for HEAD (see
RequestServer).
from xnano.requests import Response, on_head_request
@on_head_request("/status")
def status_head(self) -> Response:
return Response(status=200, headers={"x-ready": "1"})
POST requests¶
import dataclasses
from xnano import BaseGrid, Field, Web
from xnano.requests import Response, on_post_request
@dataclasses.dataclass
class Counters:
hits: int = 0
class Counter(BaseGrid):
label: str = Field(default="hits: 0")
@on_post_request("/hit")
def hit(self, ctx) -> Response:
ctx.state.hits += 1
self.label = f"hits: {ctx.state.hits}"
return Response.json({"hits": ctx.state.hits})
Web(state=Counters()).run(Counter(), port=8000)
Read JSON from the parsed request with ctx.request.json() when the client
sends a body (see Request / Response).
PUT requests¶
from xnano import BaseGrid, Field, Web
from xnano.requests import Response, on_put_request
class Document(BaseGrid):
content: str = Field(default="")
@on_put_request("/document")
def put_document(self, ctx) -> Response:
payload = ctx.request.json() if ctx.request else None
self.content = str((payload or {}).get("content", ""))
return Response.json({"content": self.content})
Web().run(Document(), port=8000)
curl -X PUT http://127.0.0.1:8000/document \
-H 'content-type: application/json' \
-d '{"content": "hello"}'
PATCH requests¶
from xnano import BaseGrid, Field, Web
from xnano.requests import Response, on_patch_request
class Profile(BaseGrid):
name: str = Field(default="anonymous")
@on_patch_request("/profile")
def patch_profile(self, ctx) -> Response:
payload = ctx.request.json() if ctx.request else {}
if "name" in (payload or {}):
self.name = str(payload["name"])
return Response.json({"name": self.name})
Web().run(Profile(), port=8000)
curl -X PATCH http://127.0.0.1:8000/profile \
-H 'content-type: application/json' \
-d '{"name": "midoriya"}'
DELETE requests¶
from xnano import BaseGrid, Field, Web
from xnano.requests import Response, on_delete_request
class Session(BaseGrid):
active: bool = Field(default=True, state=True)
@on_delete_request("/session")
def end_session(self) -> Response:
self.active = False
return Response(status=204)
Web().run(Session(), port=8000)
Other HTTP methods¶
Less common verbs use the same decorator pattern and path rules:
| Decorator | Method |
|---|---|
@on_connect_request |
CONNECT |
@on_options_request |
OPTIONS |
@on_trace_request |
TRACE |
@on_query_request |
QUERY |
from xnano.requests import Response, on_options_request
@on_options_request("/items")
def items_options(self) -> Response:
return Response(
status=204,
headers={"allow": "GET, POST, OPTIONS"},
)
Generic method registration¶
When the method is dynamic or you prefer one entry point, use
request(method, path):
from xnano import BaseGrid
from xnano.requests import Response, request
class App(BaseGrid):
@request("PATCH", "/thing")
def update(self, ctx) -> Response:
payload = ctx.request.json() if ctx.request else {}
return Response.json({"name": (payload or {}).get("name")})
method is uppercased. Unsupported method names raise ValueError. Supported
values match the dedicated decorators: GET, HEAD, POST, PUT, DELETE,
CONNECT, OPTIONS, TRACE, PATCH, QUERY.
Request and response objects¶
Incoming request¶
Immutable parsed request available as ctx.request when the server builds one
(both Web / native path and RequestServer do).
| Attribute | Meaning |
|---|---|
method |
Uppercase HTTP method |
path |
Normalized path with leading / |
query |
Multi-value mapping: str → tuple[str, ...] |
headers |
Header map (keys lowercased on from_parts) |
body |
Raw bytes |
Helpers:
request.text(encoding="utf-8")— decodebodyrequest.json()— parse JSON (Noneif body is empty)Request.from_parts(method, path, *, query_string=..., headers=..., body=..., max_body=...)— build from raw parts; raisesValueErrorifbodyexceedsmax_body(default 1 MiB)
Outgoing response¶
What a handler returns to the client.
| Attribute | Default | Meaning |
|---|---|---|
body |
b"" |
bytes or str |
status |
200 |
HTTP status code |
headers |
{} |
Response headers |
from xnano.requests import Response
Response.json({"ready": True}, status=201)
# content-type: application/json; charset=utf-8
as_bytes() encodes a string body as UTF-8. If the handler returns a non-bool
value that is not a Response, dispatch wraps it as Response(body=str(result)).
With a runtime attached, unmatched routes become 404 with body Not Found.
Handlers and context¶
Handlers that accept a ctx parameter receive the same
Context shape as UI hooks.
| Field | In request hooks |
|---|---|
ctx.state |
Application state from Web(state=...) or the runtime |
ctx.request |
Parsed Request, or None if not supplied to dispatch |
ctx.terminal / ctx.runtime |
Host / runtime facade used for the dispatch |
@on_post_request("/submit")
def submit(self, ctx) -> Response:
mode = ctx.request.query.get("mode", ())
data = ctx.request.json()
ctx.state.last = data
return Response.json({"mode": mode, "accepted": True}, status=202)
Handlers that omit ctx still work when they only need self.
Named request actions¶
Action.request(method, path) names an HTTP trigger the same way
Action.keyboard names a key. Pair it with @on_action when you want one
constant for both synthetic perform and real request matching.
from xnano import Action, BaseGrid, on_action
from xnano.requests import Response, on_get_request
GET_STATUS = Action.request("GET", "/status")
class Api(BaseGrid):
@on_get_request("/status")
def status(self) -> Response:
return Response.json({"ok": True})
@on_action(GET_STATUS)
def on_status_action(self) -> None:
...
The actions performer also exposes ctx.actions.request(method, path) as a
shortcut for perform(Action.request(...)). See Actions.
Web host and request servers¶
| Entry | Role |
|---|---|
Web(state=...).run(source, host=..., port=...) |
Browser cell UI + request hooks for the served grid |
xnano.server.native.serve_native |
Lower-level native cell server used by Web |
xnano.server.requests.RequestServer |
ThreadingHTTPServer for one grid’s request hooks |
start_request_server(grid, host=..., port=..., runtime=...) |
Bind on a daemon thread; returns the server |
serve_requests |
Alias of start_request_server |
Request-only example (no browser shell):
from xnano import BaseGrid
from xnano.requests import Response, on_get_request
from xnano.server.requests import start_request_server
class Health(BaseGrid):
@on_get_request("/health")
def health(self) -> Response:
return Response.json({"ok": True})
server = start_request_server(Health, host="127.0.0.1", port=8000)
# server.shutdown(); server.server_close()
port=0 selects a free port; read it from server.server_address[1].
Body size is capped (about 1 MiB) on the request server path; oversized bodies
get 413. See the server API.
Paths and routing¶
Matching is exact on method and normalized path:
- Paths are stripped and given a leading
/. Empty paths become"/". - Query strings are not part of the path key. They appear on
ctx.request.queryafter the route matches. - There is no path-parameter or wildcard syntax in the current matcher —
"/items"does not match"/items/1". - Registration order is preserved. A name defined on a more-derived class shadows the same method name from a base class when hooks are collected.
- Private methods (
_name) are skipped unless they carry a request-hook marker.
@on_get_request("status") # registers as "/status"
@on_get_request("") # registers as "/"
@on_get_request("/") # registers as "/"
Next¶
- Web — browser host that binds host/port
- Events & Hooks — UI
@on_*decorators - Actions —
Action.request - Context —
ctx.state,ctx.request - State — shared application state
API
xnano.requests ·
Request ·
Response ·
Web ·
RequestServer ·
Action.request