m0serve Realtime from a synchronous Python app

Views and fragments

The views table, fragments for htmx 4 and Datastar or a vocabulary of your own, a page or a fragment from one view, URLs, signed sessions.

The application layer of m0_http: a table of views over one state, HTML fragments that name their own swap target, one view answering both a page and a fragment, URLs built from the routes, and signed sessions. The views scaffold uses all of it in three files; the examples below are from it.

Views

A view is a free function:

def index(req: HTTPRequest, params: List[String], items: Items) raises -> HTTPResponse
def create(req: HTTPRequest, params: List[String], mut items: Items) raises -> HTTPResponse

The table says which kind each is, and the compiler holds it to that:

def item_urls() raises -> Views[Items]:
    var v = Views[Items]()
    v.add_loop("GET", "/health", health)
    v.add_read("GET", ITEMS, index)
    v.add_write("POST", ITEMS, create)
    v.add_read("GET", ITEM, detail)
    v.add_write("DELETE", ITEM, delete)
    return v^
registration the view gets runs
add_read the state, borrowed on a pool thread if there is a pool, else the loop
add_write the state, mut the same
add_loop no state on the event loop, never queued

add_read and add_write take on_loop=True for a view that needs the state and must run on the loop, which is where a stream is opened. params holds the route's :name captures in order. A method the path does not take is answered 405 with an Allow header, and OPTIONS on a registered path 204.

There is no middleware and no decorator. A stored view is a plain function pointer, and a closure is not one. A guard is a function returning Optional[HTTPResponse], called on the view's first lines:

var refused = require_login(req, st)
if refused:
    return refused.take()

form(req) returns None unless the body is application/x-www-form-urlencoded, so a missing form cannot be read as an empty one. The Form keeps every value of a repeated key; first("title") is the usual read. Multipart is not parsed.

Fragments

A Fragment writes its root id once and generates every attribute that targets it:

comptime Frag = Fragment[Htmx]

var f = Frag("items")
f.raw(f.el("form", "post", ITEMS, attr("class", "new"),
    void("input", attr("name", "title")),
    el("button", "", "Add"),
))
return f^.finish()

f.el(tag, verb, url, attrs, children...) is an element that swaps the fragment; f.swap(verb, url) is the attributes alone, for the builder style. Application code never types an hx- or data-on: swap attribute, and never retypes the id as #items.

Escaping is named at every hole:

call for escapes
text(x) data inside an element yes
attr(name, x) data inside an attribute; writes the quotes yes
el, void, flag markup this code wrote children are taken as given
raw(x) markup this code wrote no

Request data passed as a bare string is an injection. String stays the currency: a fragment finishes to a String.

Vocabularies

The type parameter is the client library.

Fragment[Htmx] emits hx-get/hx-post/…, hx-target and hx-swap, for htmx 4. Its verbs are get, post, put, patch, delete and query. Two htmx 4 behaviours shape an application: every 4xx answer is swapped, so an error a person may see is a fragment with the right status (page_or_fragment(..., status=422)); and a DELETE's fields travel in the query string, so a CSRF token on one goes in a header.

Fragment[Datastar] emits data-on:EVENT="@verb('url')" with no target: Datastar morphs a text/html answer into the element whose id it carries. The event follows the element, a form submitting, a field changing, anything else clicking. The URL sits inside a JavaScript string, so one carrying ', \, CR or LF is refused; url_for encodes them. Moving an application between the two is the type parameter and the script tag.

Your own. Vocabulary is a trait an application may conform to: swap writes the attributes, h.open_kind() says which element is open, and a static verbs() names the verbs the library takes. The layer refuses a verb outside that list before swap runs.

A page or a fragment

return page_or_fragment(req, render_list(items), Site("shop"))

page_or_fragment answers the bare fragment to a swap and the whole document to a navigation, deciding from the request's headers, and names all five in Vary. No view branches on a header.

header reading
HX-Request-Type decides when present: partial a fragment, full a document. htmx 4 sends it on every request
Datastar-Request: true a fragment
HX-Request: true a fragment, unless one of the two below is beside it
HX-History-Restore-Request: true a document
HX-Boosted: true a document

The third argument conforms to PageShell: a struct whose wrap(fragment) returns the document, called only when a document was asked for. status= sets the status, with its standard reason phrase.

URLs

A route is a comptime constant given to the table and to url_for:

comptime ITEM = "/items/:id"

v.add_read("GET", ITEM, detail)
var url = url_for(ITEM, String(id))

A misspelled constant is a compile error. url_for percent-encodes each value and raises when the count of values is not the pattern's.

Views[S](Mount("/shop")) registers every pattern under a prefix, and mount.url_for(ITEM, ...) puts the prefix in front. One Mount value does both, so a table moved under a prefix keeps its links.

Sessions

m0_http.session is a signed cookie and nothing else: v1.<kid>.<exp>.<subject>.<tag>, HMAC-SHA256 over the rest. issue_session signs one, verify_session refuses in the order malformed, unknown key, bad signature, expired, and session_cookie_line builds the Set-Cookie. csrf_token is a MAC over the session's own tag, so it needs no storage. Keys rotate through a ring: a session ends at its expiry, or when its key leaves the ring.

There is no session store and no password hashing; the application supplies the identity. apps/fragment_notes in the repository is the worked login.

m0_http.grant verifies a signed, expiring permission to open one stream channel, bound to a session cookie. It is how a Python application behind m0serve authorizes a held stream it does not serve itself.

Streams

A view opens a Server-Sent Events stream by subscribing the connection's slot to a registry, and must run on the loop. A Producer (the host) publishes frames to every worker. The rules the live scaffold follows:

  • Every frame is the whole state. A slow reader's outbox drops frames, and the next whole frame heals it.
  • DatastarStream(send_latest=True) gives a new subscriber the newest frame at once.
  • The registry's capacity is at least ctx.capacity.
  • What workers and the producer share lives on the page_slots page.

Not built

A template engine, middleware, named route parameters, multipart parsing, a session store and a password KDF. Each is a row of Decisions with the condition that would retire it. For an application that needs an ORM, an admin and a form library, Django behind m0serve is the answer, and the ramp is how the two share a process.

The capability rows are section N of Capabilities.