Quickstart: live updates from plain sync Django or Flask
From pip install to live multi-tab sync from one sync Django file. Every command is executed by CI on every pull request.
Ten minutes, one file, one process: a synchronous Django app whose browser tabs stay in sync — Server-Sent Events and WebSockets held by the server, no Channels, no Redis, no daphne, no second process.
The trick is that your views never stream anything. A plain sync view
approves a connection by answering with two response headers, and m0serve
holds it from there; m0pub.publish() from any view reaches every
subscriber on every worker. Django keeps what it is good at — auth,
sessions, the decision — and hands the connection to the server.
Works on macOS arm64 and Linux x86_64/aarch64, CPython 3.10–3.14. No Mojo toolchain involved. Sections 1–6 are Django; section 7 is the same four views in Flask, and section 8 runs the Django file under gunicorn to show what "degrades" means.
Every command below is copy-paste runnable, and this file is executable: CI extracts the fenced blocks and runs them against every pull request (
poe smoke-quickstart), so if you can read it, it works.
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 0.17.0
2. The app — one file
Everything lives in realtime.py: settings, four views, a page. The two
views that matter are events and ws — each is an ordinary sync view
that returns an ordinary buffered response, plus the two M0- headers.
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 --port 8000
--realtime is the whole feature: it turns the M0- headers into held
connections and wires the publish bus. Leave it off and the same app still
serves — the holds just degrade to short responses.
4. Verify — the same checks CI runs
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)
The id: line is not decoration: every publish takes a globally unique
number from a shared atomic, so a client that reconnects with
Last-Event-ID is never re-sent what it already has.
5. The part you came for
Open http://127.0.0.1:8000/ in two browser tabs and type in either.
Both update instantly. The page is a WebSocket client; the curl above was
SSE; a publish from any view reaches both transports at once — and all of
it gated by synchronous Django views.
6. Scale out — one publish, every worker
Stop the server (Ctrl-C) and restart with workers. Publishing from a view
on any worker reaches subscribers on every worker: the bus and the shared
id counter are created before the fork, so ids stay unique across all of
a server's workers with no coordination in your code. (They belong to the
server's lifetime — a fresh server numbers from 1 again, which is why
Last-Event-ID replay is scoped to a running server, not to history.)
m0serve realtime:application --realtime --workers 2 --health-path /health --port 8000
curl -sf --retry 20 --retry-delay 1 --retry-all-errors http://127.0.0.1:8000/health > /dev/null
curl -sN --max-time 6 "http://127.0.0.1:8000/events?channel=news" > stream2.txt &
sleep 1
RESPONSE=$(curl -s -X POST -d channel=news -d msg="hello every worker" http://127.0.0.1:8000/publish)
echo "$RESPONSE"
echo "$RESPONSE" | grep -q '"workers": 2'
for i in $(seq 1 25); do
grep -q "data: hello every worker" stream2.txt 2>/dev/null && break
sleep 0.2
done
grep "data: hello every worker" stream2.txt
SECOND_ID=$(grep -oE "^id: [0-9]+" stream2.txt | head -1 | cut -d' ' -f2)
test -n "$SECOND_ID"
echo "publish reached 2 workers; delivered (event id $SECOND_ID)"
# "No second process" is a checkable claim, so check it: the whole server is
# a supervisor and its two workers, every one of them the m0serve binary.
# And the wheel required nothing else to be installed beside it.
test "$(pgrep -x m0serve | wc -l | tr -d ' ')" -eq 3
pip show m0serve | grep -E '^Requires:\s*$'
echo "one process tree, no dependencies"
What this block proves and what it does not: the publish's own response
says the frame reached both workers' buses, and a subscriber received it.
Which worker that subscriber landed on is the kernel's choice. The
repository's smoke-django-realtime pins one subscriber on each worker
(by pausing the first worker while the second connects) and asserts the far
one hears it; the Flask gate in the next section does the same.
7. The same four views in Flask
Nothing above was Django's doing: the server reads two response headers and
a publish() call, and any WSGI framework can produce those. Here is the
same application in Flask — the routes, the headers and the call are
identical; only the framework's spelling changes, plus one flag on the
WebSocket route that Werkzeug's router requires.
pip install --quiet flask
cat > realtime_flask.py <<'PY'
"""The quickstart's four views, in Flask, served by m0serve."""
from flask import Flask, Response, jsonify, request
from m0serve import m0pub
app = Flask(__name__)
@app.get("/events")
def events():
# Same two headers, same meaning: m0serve holds the connection and
# subscribes it; Flask's part ends when the view returns.
response = Response(": connected\n\n", mimetype="text/event-stream")
response.headers["M0-Hold"] = "stream"
response.headers["M0-Channel"] = request.args.get("channel", "news")
return response
@app.route("/ws", websocket=True)
def ws():
# The one Flask-specific line. Werkzeug's router refuses to match an
# upgrade request (`Connection: Upgrade`, `Upgrade: websocket`) to an
# ordinary HTTP rule -- it answers 400 before any view runs -- so the
# rule that approves a socket must say so. Django has no such check.
response = Response("")
response.headers["M0-Hold"] = "websocket"
response.headers["M0-Channel"] = request.args.get("channel", "news")
return response
@app.post("/ws/message")
def ws_message():
# Flask has no CSRF middleware to exempt; the path is reserved by the
# server either way, so only the synthesised POST reaches this view.
channel = request.headers.get("M0-Channel", "news")
m0pub.publish(channel, request.get_data(as_text=True))
return jsonify(ok=True)
@app.post("/publish")
def publish():
workers, event_id = m0pub.publish_with_id(
request.form.get("channel", "news"), request.form.get("msg", "")
)
return jsonify(workers=workers, id=event_id)
PY
Serve it with workers, exactly as the Django file was served:
m0serve realtime_flask:app --realtime --workers 2 --health-path /health --port 8000
The checks are the ones section 6 ran, plus the WebSocket handshake: a view
that answered M0-Hold: websocket gets the RFC 6455 upgrade it could not
perform itself, and curl can show the 101.
curl -sf --retry 20 --retry-delay 1 --retry-all-errors http://127.0.0.1:8000/health > /dev/null
curl -sN --max-time 6 "http://127.0.0.1:8000/events?channel=news" > flask.txt &
sleep 1
RESPONSE=$(curl -s -X POST -d channel=news -d msg="hello from flask" http://127.0.0.1:8000/publish)
echo "$RESPONSE"
echo "$RESPONSE" | grep -Eq '"workers": ?2'
for i in $(seq 1 25); do
grep -q "data: hello from flask" flask.txt 2>/dev/null && break
sleep 0.2
done
grep "data: hello from flask" flask.txt
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
"http://127.0.0.1:8000/ws?channel=news" || true)
test "$code" = "101"
echo "Flask: SSE delivered across 2 workers, WebSocket upgraded ($code)"
{"id":1,"workers":2}
data: hello from flask
Flask: SSE delivered across 2 workers, WebSocket upgraded (101)
The repository drives this exact file — extracted from this page, not
copied — with the Django rows' raw RFC 6455 probe, one socket pinned on
each worker (poe smoke-flask-realtime), so a message sent on one
worker's socket reaching a Flask view and coming back on the other
worker's socket is asserted on every pull request.
8. Under gunicorn, the same views degrade
"Degrade, not break" is also a checkable claim. Stop m0serve and serve the
Django file with gunicorn: the M0- headers mean nothing to it, so the
hold views answer as the short plain responses they are, the upgrade is an
ordinary 200, and publish() reports that it reached no worker — and
raises nothing.
pip install --quiet gunicorn
gunicorn --bind 127.0.0.1:8000 realtime:application
curl -sf --retry 20 --retry-delay 1 --retry-all-errors http://127.0.0.1:8000/ > /dev/null
# Under m0serve this connection is held open; here it must finish inside
# the deadline with the body the view wrote, or curl exits 28 and so does
# this block.
curl -si --max-time 5 "http://127.0.0.1:8000/events?channel=news" > degraded.txt
grep -q '^HTTP/1.1 200' degraded.txt
grep -q '^: connected' degraded.txt
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
"http://127.0.0.1:8000/ws?channel=news")
test "$code" = "200"
curl -s -X POST -d channel=news -d msg="nobody is held" http://127.0.0.1:8000/publish | grep -q '"workers": 0'
echo "under gunicorn: /events answered $(grep -c '' degraded.txt) lines and closed, /ws answered $code, publish reached 0 workers"
In your real project
The whole integration is what you just read: two headers on a view that
approves a subscription, m0pub.publish() wherever something happens, and
m0serve myproject.wsgi:application --realtime. No settings changes, no
INSTALLED_APPS, no middleware, and the views degrade — not break — under
any other WSGI server, which section 8 just showed.
The fuller demo, with token auth, channel isolation, and static files
served without entering Python, is
apps/django_realtime in the repository; its
behaviour is pinned by smoke-django-realtime and a raw RFC 6455 probe.
Three things the server does not decide for you
The hold pattern moves the connection out of Python, not the authorisation. What that leaves you:
- A view that approves a hold is the access check. Nothing downstream re-asks. The demo above gates on a query token because it is a demo; use whatever your project already uses, and remember the view runs before the connection becomes a stream, which is exactly where you want the decision.
publish()reaches every subscriber of a channel, on every worker. If the channel name comes from a request, so does the blast radius — an endpoint that publishes what it is told, to the channel it is told, is a fan-out primitive for whoever can reach it. The server refuses only its own reserved namespace (channels opening with a\x01byte, which address connection slots internally); everything else is your namespace to police.- A WebSocket handshake carries no
Origincheck. The server does not make one, because it cannot know your policy. If you authenticate sockets with cookies, a page on any origin can open one and the browser will attach them — checkOriginin 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, and the
server reserves that path: a request for it from the network is answered
404 before Django sees it, so the view can treat what it receives as
genuinely server-synthesised. That reservation is why the view may be
csrf_exempt without it becoming an open endpoint.