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\x01byte); every other name is yours to police.publish(channel, data)sendsdataas one event. Each line of it becomes onedata: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; a0means 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.
publishandws_messagearecsrf_exemptabove socurlcan 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
Origincheck. 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. 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.