m0serve Realtime from a synchronous Python app

Quickstart

Live updates between browser tabs from one file of synchronous Django, in five steps. CI runs every command on the page.

One file of synchronous Django whose browser tabs stay in sync. The server holds the Server-Sent Events streams and the WebSockets; the views only approve them. It needs neither Channels nor Redis, and runs as one process.

A sync view approves a connection by setting two response headers, and m0serve holds it from there. m0pub.publish() from any view reaches every subscriber on every worker. Django keeps auth, sessions and the decision; the server keeps the connection.

macOS arm64 and Linux x86_64/aarch64, CPython 3.10 to 3.14. No Mojo toolchain. CI extracts the fenced commands below and runs them on every pull request (poe smoke-quickstart).

1. Install

mkdir -p m0serve-quickstart && cd m0serve-quickstart
python3 -m venv .venv
source .venv/bin/activate
pip install --quiet m0serve django
m0serve --version
m0serve 1.15.0

2. The app

Everything is in realtime.py: settings, four views, a page. events and ws are the two that matter. Each is an ordinary sync view returning an ordinary buffered response with two M0- headers added.

cat > realtime.py <<'PY'
"""Live updates from plain sync Django, served by m0serve."""
import django
from django.conf import settings
from django.core.wsgi import get_wsgi_application
from django.http import HttpResponse, JsonResponse
from django.urls import path
from django.views.decorators.csrf import csrf_exempt

from m0serve import m0pub

settings.configure(
    DEBUG=False,
    ALLOWED_HOSTS=["*"],
    ROOT_URLCONF=__name__,
    SECRET_KEY="quickstart-not-a-secret",
)
django.setup()

PAGE = """<!doctype html>
<meta charset="utf-8">
<title>m0serve quickstart</title>
<style>body{font:16px system-ui;margin:2rem auto;max-width:32rem}</style>
<h1>live channel: news</h1>
<form id="f"><input id="m" autocomplete="off" placeholder="say something">
<button>send</button></form>
<ul id="log"></ul>
<script>
const ws = new WebSocket(`ws://${location.host}/ws?channel=news`);
ws.onmessage = e => {
  const li = document.createElement("li");
  li.textContent = e.data;
  document.getElementById("log").prepend(li);
};
document.getElementById("f").onsubmit = e => {
  e.preventDefault();
  const m = document.getElementById("m");
  ws.send(m.value);
  m.value = "";
};
</script>"""


def index(request):
    return HttpResponse(PAGE)


def events(request):
    # An ordinary buffered response. `M0-Hold: stream` tells m0serve to keep
    # this connection open and subscribe it to the channel; the body becomes
    # the head of the stream. Django's part in the connection ends here --
    # and this is where your real auth goes, because the view runs first,
    # with sessions and permissions in hand. Under gunicorn the headers are
    # ignored and the view degrades to a short plain response.
    response = HttpResponse(": connected\n\n", content_type="text/event-stream")
    response["M0-Hold"] = "stream"
    response["M0-Channel"] = request.GET.get("channel", "news")
    return response


def ws(request):
    # The same decision, a different transport. WSGI cannot produce the 101
    # handshake a WebSocket needs, so the view does not try: it APPROVES the
    # upgrade, and m0serve performs the RFC 6455 handshake it cannot.
    response = HttpResponse("")
    response["M0-Hold"] = "websocket"
    response["M0-Channel"] = request.GET.get("channel", "news")
    return response


@csrf_exempt
def ws_message(request):
    # Each inbound WebSocket message arrives here as a plain POST -- a sync
    # view with request.body in its hands. Rebroadcast it: every subscriber
    # of the channel hears it, WebSocket clients as frames, SSE clients as
    # events, on every worker, the sender included.
    channel = request.headers.get("M0-Channel", "news")
    m0pub.publish(channel, request.body.decode("utf-8", "replace"))
    return JsonResponse({"ok": True})


@csrf_exempt
def publish(request):
    # Publishing is one os.write per worker plus one atomic fetch-add for
    # the event id -- no server API, no connection, callable from any view,
    # management command, or cron job.
    workers, event_id = m0pub.publish_with_id(
        request.POST.get("channel", "news"), request.POST.get("msg", "")
    )
    return JsonResponse({"workers": workers, "id": event_id})


urlpatterns = [
    path("", index),
    path("events", events),
    path("ws", ws),
    path("ws/message", ws_message),
    path("publish", publish),
]

application = get_wsgi_application()
PY

3. Serve it

m0serve realtime:application --realtime --health-path /health --host 127.0.0.1 --port 8000

--realtime turns the M0- headers into held connections and wires the publish bus. Without it the same app still serves, the hold views return short responses, and a publish() reaches nobody. --host 127.0.0.1 binds this machine alone; without it the server listens on every interface.

4. Verify

Wait for the server, subscribe a client, publish, and watch the event arrive on the held connection:

curl -sf --retry 20 --retry-delay 1 --retry-all-errors http://127.0.0.1:8000/health > /dev/null
echo "server is up"

curl -sN --max-time 6 "http://127.0.0.1:8000/events?channel=news" > stream.txt &
sleep 1

curl -s -X POST -d channel=news -d msg="hello from curl" http://127.0.0.1:8000/publish
echo

for i in $(seq 1 25); do
  grep -q "data: hello from curl" stream.txt 2>/dev/null && break
  sleep 0.2
done
grep "data: hello from curl" stream.txt
FIRST_ID=$(grep -oE "^id: [0-9]+" stream.txt | head -1 | cut -d' ' -f2)
test -n "$FIRST_ID"
echo "SSE delivery verified (event id $FIRST_ID)"
{"workers": 1, "id": 1}
data: hello from curl
SSE delivery verified (event id 1)

Every publish takes a unique id from a counter shared across workers, and the server journals the last 64 of them (--replay-frames). A client that reconnects with Last-Event-ID is not re-sent what it already has, and is sent what it missed; one further behind than the journal reaches is sent an m0-gap event instead, so listen for it and fetch the current state there rather than polling beside the stream.

5. Two tabs

Open http://127.0.0.1:8000/ in two browser tabs and type in either. Both update. The page is a WebSocket client and the curl above was SSE; one publish reaches both, and every connection was approved by a synchronous Django view.

In your real project

The integration is two headers on the view that approves a subscription, m0pub.publish() wherever something happens, and m0serve myproject.wsgi:application --realtime. No settings changes, no INSTALLED_APPS entry, no middleware. Under any other WSGI server the same views return short plain responses.

The binary resolves libpython from the python3 on PATH, so a start script activates the project's virtualenv, or puts its bin/ first on PATH, before it runs m0serve; MOJO_PYTHON_LIBRARY names the library outright.

A fuller example, with token auth, channel isolation and static files served by the server, is apps/django_realtime; CI drives it with a raw RFC 6455 probe.

What the server leaves to you

The hold moves the connection out of Python. Authorization stays in the view.

  • The view that approves a hold is the access check. Nothing downstream asks again. The view runs before the connection becomes a stream, with sessions and permissions in hand, so use whatever your project already uses.
  • publish() reaches every subscriber of a channel, on every worker. If the channel name comes from a request, so does the blast radius. The server refuses only its own reserved namespace (channel names opening with a \x01 byte); every other name is yours to police.
  • publish(channel, data) sends data as one event. Each line of it becomes one data: field, and the client joins the fields with a newline again, so a payload keeps its line breaks. The return value is the number of worker channels written; a 0 means the event went nowhere: a frame over the size limit, a refused channel, or no m0serve underneath, as under gunicorn.
  • A view a browser form posts to is yours to protect. publish and ws_message are csrf_exempt above so curl can post to them; a form in your own page sends Django's CSRF token as usual, and the view keeps the decorator off.
  • The WebSocket handshake carries no Origin check. If you authenticate sockets with cookies, a page on any origin can open one and the browser will attach them. Check Origin in the view that approves the upgrade, or authenticate with something a cross-site page cannot supply.

Inbound WebSocket messages arrive as a POST to /ws/message. The server reserves that path: a request for it from the network is answered 404 before Django sees it, which is why the view can be csrf_exempt.

Next

After the quickstart runs this application under two workers, then the same four views in Flask, then the Django file under gunicorn to show what the views do there.