Flask, Django and FastAPI answering real requests, with no socket underneath
Every Python web framework bottoms out in the same two lines, whatever it calls them:
sock.bind((host, port))
sock.listen(backlog)
A browser tab does not have that. There is no TCP stack in a page, no file
descriptor to bind, and no amount of WebAssembly changes it. So the interesting
question is not whether you can run Flask's Python in a browser, because you
can. It is what happens when someone types flask run.
What is Pyodide's, and what is not
Worth being exact about this up front, because the rest of the post only makes sense once the line is drawn.
Pyodide compiled CPython to WebAssembly. That is their work, it is a large piece of engineering, and nothing here reimplements any of it. The interpreter running in Vivari is Pyodide's build of CPython 3.14, with its standard library and its C extension modules, unmodified. When this post says "the interpreter", it means theirs.
What Pyodide does not ship, because it cannot, is an operating system: a filesystem shared with other programs, a process table, a port registry, a socket. Vivari is the layer that supplies those. This post is about exactly one of them, and it is the one people ask about first.
A socket that accepts everything and carries nothing
The obvious expectation is that import socket fails and the frameworks fail
loudly with it. That is not what happens, and the real behaviour is the reason
this needed designing rather than documenting.
There is a socket module, inherited from the POSIX layer underneath the
WebAssembly build. connect() succeeds. bind() succeeds. listen() succeeds.
Then no bytes ever move, and select() never reports the socket readable. That
is not a defect anybody introduced; it is what a POSIX shaped API looks like
when there is no network stack beneath it to say no.
Point Django's development server at that and it does the worst possible thing. It prints its banner:
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
and then answers nothing, for as long as you leave it running. Nothing raised, nothing logged, no clue in the output that the server you are looking at is incapable of serving. A missing feature that announces itself is a small problem. A missing feature that looks like a working one costs somebody an afternoon.
So the design rule for everything below is that a socket must never be the thing we quietly rely on.
The launcher is not a Python program
Here is the piece that makes the rest possible, and it is an accident of how Vivari is put together rather than anything clever about Python.
python in Vivari is not a Python program. It is a Node program, running on
Vivari's Node-compatible runtime, and it boots Pyodide inside itself. That
runtime has a real require("http"), a real event loop, and a working
server.listen(port), because Vivari's kernel implements virtual ports: an
Express app in this environment gets a preview tab, and it does so without a TCP
stack either.
Which means the interpreter that cannot bind a port is running inside a process that already can.
browser -> service worker -> kernel virtual port
|
v
guest Node http.createServer() <- the "socket"
|
JSON, base64 body
v
Pyodide
|
WSGI environ / ASGI scope
v
your Flask or FastAPI app
Nothing in the Python half of that diagram believes it is talking to a network. It is handed a request the way a WSGI server hands one over, because that is precisely what the layer above it has become.
Writing the environ by hand
WSGI is a good specification to be stuck with here, because it was never about
sockets in the first place. PEP 3333 says a server calls
app(environ, start_response) and reads the iterable that comes back. It says
nothing about where the bytes came from.
So the Node side parses the request it received, and the Python side builds the dict:
environ = {
"REQUEST_METHOD": d["method"],
"SCRIPT_NAME": d.get("root_path", ""),
"PATH_INFO": d["path"],
"QUERY_STRING": d.get("query", ""),
"SERVER_PROTOCOL": "HTTP/" + d.get("http_version", "1.1"),
"wsgi.input": io.BytesIO(body),
"wsgi.errors": sys.stderr,
"wsgi.multithread": False,
"wsgi.multiprocess": False,
"wsgi.run_once": False,
}
wsgi.multithread and wsgi.multiprocess are both False and, unusually, both
are honestly False. There is one interpreter and there are no OS threads, so
an app that checks those flags before deciding whether a cache can be a plain
dict gets a true answer rather than a conservative one.
Request bodies and response bodies cross the JavaScript and Python boundary as
base64 inside a JSON string. That is not elegant and it was chosen anyway: JSON
strings convert to Python str with no ambiguity, whereas handing typed arrays
across the boundary means reasoning about proxy object lifetimes at every call
site. The bridge is on the request path, so the failure mode that matters is a
subtle one, not a slow one.
That is a claim you should not take on trust, so here it is running. The code
below is real CPython executing in this page, and you can edit it and run it
again. It builds the environ above, hands it to a WSGI application, and passes
the whole thing through wsgiref.validate, which is CPython's own PEP 3333
conformance checker. If the standard library's validator returns without
raising, what the bridge gives your app is a real WSGI call and not an
impression of one.
Two honest notes about that demo. It uses only the standard library, because the CPython core is stable here while Flask, FastAPI and Django are shipped as experimental templates, and a live demo is the wrong place to blur that distinction. And it exercises the conversion rather than the tunnel: the environ, the application call and the validation are the real thing, while the part that carries bytes in from the browser is the guest Node server described above. The first run also has to fetch the interpreter, so it is slower than the ones after it, which is the subject of the next post.
The ASGI scope, and the bug that took longest
ASGI needs more of the same, plus one thing that is easy to get wrong and produces a symptom that points nowhere near the cause.
Previews in Vivari are served under a path prefix, /preview/<port>/. The
preview tunnel strips that prefix before the request reaches your process and
sets x-forwarded-prefix so the app can learn what it was mounted under. The
Node side reads that header and passes it along as root_path.
The obvious thing to do next is to set scope["root_path"] to the prefix and
scope["path"] to the path the tunnel handed over. That is wrong, and it is
wrong in a way that only shows up on Mount().
ASGI defines path as the full request path, including root_path.
root_path names the prefix, it does not remove it. Starlette's
get_route_path() subtracts root_path from path to get the routable
remainder, so if you hand it a path that has already been stripped, it subtracts
a prefix that is not there. Top-level routes still match, because the subtraction
falls through harmlessly. Every Mount() misses, including the StaticFiles
mount that a FastAPI app usually has, so the app comes up, the JSON endpoints
work, and the CSS 404s.
The fix is to put the prefix back before building the scope:
_vv_root = d.get("root_path", "")
_vv_path = _vv_root + d["path"] if _vv_root else d["path"]
WSGI needs no equivalent, and the reason is a small piece of design history
worth appreciating. SCRIPT_NAME and PATH_INFO are already the split form:
the prefix and the remainder are separate keys, so there is nothing to subtract
and nothing to get wrong. ASGI collapsed them into one string plus a length
convention, and this is the bug that convention buys you.
No threads, which FastAPI has an opinion about
Starlette and FastAPI let you write def endpoints as well as async def ones.
A sync endpoint cannot be awaited, so Starlette runs it on a threadpool through
anyio.to_thread.run_sync, which ends at threading.Thread, which under Pyodide
raises RuntimeError: can't start new thread.
That would make every synchronous route in a FastAPI app a 500, which is most routes in most tutorials.
There is one interpreter and nothing else can be running in it, so the threadpool is not buying isolation here, only a thread that does not exist. Running the callable inline is the correct answer for this execution model:
import anyio.to_thread as _vv_att
async def _vv_run_sync(func, *args, **kwargs):
return func(*args)
_vv_att.run_sync = _vv_run_sync
Starlette reads run_sync at call time rather than binding it at import, so
this takes effect for every sync route and every sync dependency, including ones
defined after the patch.
The entrypoints do not import what they are named after
uvicorn, flask and gunicorn exist as commands. None of them imports the
package it is named after.
uvicorn main:app --port 8000 # FastAPI and other ASGI apps
flask --app main run --port 8000 # Flask
gunicorn wsgi:application --bind 0.0.0.0:8000 # Django and any other WSGI app
Each one parses argv the way the real tool does, works out which module and
attribute you meant, and hands that to the bridge. Calling them shims
undersells what they have to get right: the observable contract of gunicorn is
"your WSGI app is now served on this port", and that contract is met. What
cannot be met is gunicorn's process model, so those flags say so out loud rather
than being accepted and ignored. -w 4 warns that there is exactly one worker.
--worker-class gevent stops, because serving you a different concurrency model
than the one you asked for is not a warning-level event.
Choosing gunicorn as the WSGI entrypoint rather than writing a django command
is the reason Django works at all here. gunicorn is the seam every WSGI
framework already reaches for, so Bottle and Pyramid arrive for free.
The argv parsing has one decision in it that is more interesting than argv
parsing has any right to be. To know whether --log-level debug main:app has
two tokens or three, you need to know which flags take a value. gunicorn's own
--help declares about a dozen store-true flags and several dozen that take a
value, and the shim hardcodes the boolean list rather than the value list.
That is smaller, but the real reason is which way it fails. Mistake a boolean
for a value-taker and it eats the next token, which is the app spec, and the
command exits with no app specified. The user sees that immediately. Mistake a
value-taker for a boolean and its value is left lying in argv to be picked up as
the app spec, and the server cheerfully starts serving something nobody asked
for. Both are bugs. Only one of them is quiet.
Django's runserver is refused, on purpose
python manage.py runserver does not run. It stops and says why, and points at
the command that works.
This is the only refusal in the Python support that blocks something people
demonstrably want, so it is worth defending. Every other entrypoint here hands
you an app object, which is a thing the bridge can serve. runserver binds the
socket itself. Given Pyodide's socket, that means it would start, print its
banner, and answer nothing, which is the failure mode described at the top of
this post.
The rest of manage.py is untouched. migrate, makemigrations, shell and
createsuperuser all run normally, because none of them is a socket.
Keeping CPython's own HTTP server, minus the socket
python -m http.server is the neatest case, because it shows what the bridge
makes possible when the thing being served is already in the standard library.
Reimplementing a static file server is easy and would have been the wrong
answer. The value of -m http.server is that it is the directory listing you
know, the mimetypes table you know, the Range and If-Modified-Since
handling you know, and the 404 you know. A lookalike is worth much less than the
real one.
So the handler stays and the socket goes. BaseHTTPRequestHandler does all of
its I/O through self.rfile and self.wfile, which
StreamRequestHandler.setup() builds from self.connection by calling
makefile(). It never touches the socket directly. So a socket, as far as that
class is concerned, is an object with makefile() and sendall():
class _VvConn:
"""Everything StreamRequestHandler.setup() asks of a socket, and no more."""
def __init__(self, data):
self._data = data
self.out = bytearray()
def makefile(self, mode="rb", bufsize=-1, *a, **k):
return io.BytesIO(self._data) if "r" in mode else io.BytesIO()
def sendall(self, b):
self.out += bytes(b)
Feed it the raw request bytes, let CPython's own SimpleHTTPRequestHandler do
the work, and collect the raw response bytes out of out. The same guest Node
server carries them that carries Flask's.
Duck typing gets used to defend some questionable things. This is the case it was invented for.
Two things that fell out for free
Neither of these was designed. Both are consequences of the constraints, noticed afterwards, which is usually a sign the layering is right.
There is an exact moment when the filesystem is consistent. A served app that writes a file, an upload or a SQLite commit, needs those writes mirrored back into the editor. On a normal server there is no clean moment to do that, because another thread is always mid-write. Here the handler has returned and there are no threads, so the end of a request is a point where "everything the app has written" is a complete and correct answer. Persistence happens there, after the response bytes are already out, and costs nothing on a request that wrote nothing.
--reload works, without a watcher thread or a subprocess. Real reloaders
need both: something to poll the filesystem, and a process to kill and respawn.
Neither exists here and neither is needed. The virtual filesystem already pushes
change notifications, because that is how Vite's dev server sees your edits. And
there is no server process to restart, because your app is an object imported
into the bridge's process, so a reload re-imports the module and rebinds one
name. A failed re-import puts the previous modules back and the old app keeps
serving, which matters because a syntax error in a file you just saved is the
normal case rather than the exceptional one.
What is honest to claim
The Python web frameworks here are shipped as experimental templates. That
is the project's own word for them, it is what the README says, and this post is
not the place to quietly upgrade it. Vivari's CPython core, pip and the REPL
are stable. Flask, FastAPI and Django sit above them and are not.
The limits worth knowing before you try it:
- Buffered request and response only. Each request is converted, run and returned whole. No streaming responses, no Server-Sent Events, no WebSocket from Python. This is a property of the bridge, not a bug in it.
- One request at a time. One interpreter, no threads.
runserveris refused, as described above.- Generate your URLs. The preview is served under a prefix, and the bridge
tells your framework what it is, so
url_for(),reverse()andrequest.url_for()stay inside the preview. A hardcoded/aboutescapes it. - Nothing here makes an unbuilt C extension work.
psycopg2still has no wheel. Streamlit still stops onwatchdog. This bridge is about serving an app you can already import.
What is not caveated: a real Flask app, a real FastAPI app with real Starlette routing, and a real Django app served through real gunicorn argv handling, all answering real HTTP requests in a browser tab with no server anywhere and no socket underneath.
The pattern, again
Every one of these posts has ended in the same place from a different direction.
The synchronous bridge works because Atomics.wait is
a real blocking primitive and the whole system was built to respect it. Node's
real lib/ works because Node already had a
seam and we cut along it.
WSGI and ASGI are that same seam, and Python drew it years before any of this existed, with none of it in mind. The frameworks were already written against an interface that says nothing about sockets. All that was missing was something standing on the other side of it.
The next post is about a different Python problem entirely: why the first
python command in a session costs nearly two seconds, why the second one used
to as well, and what PEP 552 has to do with fixing it. There is more detail on
everything above in the Python docs.
Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no per-seat fee, self-host every asset. The code is on GitHub and the Studio runs in your browser.