# 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
```bash setup
mkdir -p m0serve-quickstart && cd m0serve-quickstart
python3 -m venv .venv
source .venv/bin/activate
pip install --quiet m0serve django
m0serve --version
```
```text
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.
```bash setup
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 = """
m0serve quickstart
live channel: news
"""
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
```bash serve
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:
```bash verify
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)"
```
```text
{"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 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.)
```bash serve
m0serve realtime:application --realtime --workers 2 --health-path /health --port 8000
```
```bash verify
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.
```bash setup
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:
```bash serve
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.
```bash verify
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)"
```
```text
{"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.
```bash setup
pip install --quiet gunicorn
```
```bash serve
gunicorn --bind 127.0.0.1:8000 realtime:application
```
```bash verify
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`](https://github.com/codetalcott/mojo-http/tree/main/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 `\x01` byte, which
address connection slots internally); everything else is your namespace
to police.
- **A WebSocket handshake carries no `Origin` check.** 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 — 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`, 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.