An asyncio-based Python port of the PeerJS signalling server, the WebSocket
broker that PeerJS clients use to discover each other and exchange WebRTC offers/answers/candidates.
It's feature-complete and wire-compatible with the original: WebSocket session handling, reconnect, and the HTTP API are all implemented, plus a few deployment conveniences, like rate limiting, proxied-IP support, and bounded queues.
- WebSocket signalling at
{path}/peerjswithid/token/keyquery-param auth - Reconnect support (same
id+tokenre-attaches the socket and drains queued messages) - Per-peer message queueing for offline destinations, with periodic
EXPIREsweeps - Periodic dead-connection reaping based on heartbeat pings
- HTTP API:
GET {path}{key}/id(fresh peer id) andGET {path}{key}/peers(peer list, opt-in viaallow_discovery) - CORS header support
- Fixed-window per-IP rate limiting on the HTTP API
- Optional
X-Forwarded-Fortrust for real client IPs when running behind a reverse proxy
- Python 3.13+
websockets>=14(the only runtime dependency, installed automatically below)
pip install python-peerjs-serverFor local development (editable install with test/lint tooling):
pip install -e ".[dev]"Run as a console script:
peerjs-server --port 9000 --key peerjsor as a module:
python -m python_peerjs_server --port 9000 --key peerjs| Flag | Env var | Default | Description |
|---|---|---|---|
--host | PEERJS_HOST | :: | Bind host |
--port | PEERJS_PORT | 9000 | Bind port |
--key | PEERJS_KEY | peerjs | Access key clients must supply |
--path | PEERJS_PATH | / | Path prefix for HTTP/WS routes |
--allow-discovery | PEERJS_ALLOW_DISCOVERY | off | Expose GET /peers |
--cors-origin | PEERJS_CORS_ORIGIN | unset | CORS origin for HTTP API responses |
--log-level | PEERJS_LOG_LEVEL | INFO | Logging level |
Other server behaviour (timeouts, rate limits, queue caps, proxy trust) is configured via
python_peerjs_server.config.Config when embedding the server programmatically; see
src/python_peerjs_server/config.py for the full set of fields and their
defaults.
importasynciofrompython_peerjs_server.configimportConfigfrompython_peerjs_server.mainimportpeerjs_serveasyncio.run(peerjs_serve(Config(port=9000, key="peerjs")))If you're running via the CLI, --log-level/PEERJS_LOG_LEVEL already configures this for you (it calls
logging.basicConfig); the rest of this section is for embedding python_peerjs_server in your own app.
Every module logs under the python_peerjs_server namespace (logging.getLogger(__name__)), so that one logger name
is the knob to turn:
importlogging# See python_peerjs_server logs at INFO+ on stderrlogging.getLogger("python_peerjs_server").setLevel(logging.INFO)
logging.getLogger("python_peerjs_server").addHandler(logging.StreamHandler())
# Or, if your app already configured the root logger, just set the level;# propagation does the restlogging.getLogger("python_peerjs_server").setLevel(logging.DEBUG)
# Silence a noisy sub-logger specificallylogging.getLogger("python_peerjs_server.services.web_socket_session").setLevel(logging.WARNING)A NullHandler is attached by default, so the library stays silent unless logging is configured (either on
python_peerjs_server
or higher up).
This server only implements the signalling protocol; clients connect using the PeerJS client library (docs, GitHub):
constpeer=newPeer("some-id",{host: "localhost",port: 9000,path: "/",key: "peerjs",});Runnable versions of the standalone and FastAPI embedding patterns live in examples/:
standalone.py and fastapi_app.py.
The PeerJS client calls the HTTP API (GET {path}{key}/id) before it opens the WebSocket. If your page is served
from a different origin than the broker (e.g. a Vite/CRA dev server on localhost:5173 talking to a broker on
localhost:9000), the browser will block that request with a CORS error unless you set --cors-origin /
PEERJS_CORS_ORIGIN / Config.cors_origin to the exact origin (scheme + host + port) serving the page:
peerjs-server --port 9000 --key peerjs --cors-origin http://localhost:5173It's unset by default (no Access-Control-Allow-Origin header sent), which is fine for same-origin deployments.
peerjs_serve() is just a coroutine; you can run it inside whatever process already hosts your app instead of standing
up a separate service. Pass handle_signals=False so it doesn't fight your app for SIGINT/SIGTERM handling, and
pick a port distinct from your main app's.
Runnable versions of both patterns below live in examples/: tornado_app.py
and flask_app.py.
Tornado runs on the standard asyncio event loop, so the broker can simply be scheduled as another task on it:
importasyncioimporttornado.ioloopimporttornado.webfrompython_peerjs_server.configimportConfigfrompython_peerjs_server.mainimportpeerjs_serveclassMainHandler(tornado.web.RequestHandler):
defget(self) ->None:
self.write("hello from tornado")
asyncdefmain() ->None:
app=tornado.web.Application([(r"/", MainHandler)])
app.listen(8888)
asyncio.create_task(peerjs_serve(Config(port=9000, key="peerjs"), handle_signals=False))
awaitasyncio.Event().wait() # run foreverif__name__=="__main__":
asyncio.run(main())Flask's dev server is sync/WSGI and blocks its own thread, so the broker has to run on an event loop in a separate thread:
importasyncioimportthreadingfromflaskimportFlaskfrompython_peerjs_server.configimportConfigfrompython_peerjs_server.mainimportpeerjs_serveapp=Flask(__name__)
@app.get("/")defindex() ->str:
return"hello from flask"defrun_python_peerjs_server() ->None:
asyncio.run(peerjs_serve(Config(port=9000, key="peerjs"), handle_signals=False))
if__name__=="__main__":
threading.Thread(target=run_python_peerjs_server, daemon=True).start()
app.run(port=8888)For an ASGI app (FastAPI, Starlette) you can instead start it from a lifespan/startup hook with asyncio.create_task,
the same way as the Tornado example: both share a loop with no extra thread needed.
pip install -e ".[dev]"# install with dev/test/lint dependenciespytest # run tests
ruff check src # lint
ruff format src # format
mypy src # type check
bandit -r src # security scanMIT, see LICENSE.