# 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.