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.
import asyncio
from python_peerjs_server.config import Config
from python_peerjs_server.main import peerjs_serve
asyncio.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:
import logging
# See python_peerjs_server logs at INFO+ on stderr
logging.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 rest
logging.getLogger("python_peerjs_server").setLevel(logging.DEBUG)
# Silence a noisy sub-logger specifically
logging.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):
const peer = new Peer("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:
import asyncio
import tornado.ioloop
import tornado.web
from python_peerjs_server.config import Config
from python_peerjs_server.main import peerjs_serve
class MainHandler(tornado.web.RequestHandler):
def get(self) -> None:
self.write("hello from tornado")
async def main() -> None:
app = tornado.web.Application([(r"/", MainHandler)])
app.listen(8888)
asyncio.create_task(peerjs_serve(Config(port=9000, key="peerjs"), handle_signals=False))
await asyncio.Event().wait() # run forever
if __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:
import asyncio
import threading
from flask import Flask
from python_peerjs_server.config import Config
from python_peerjs_server.main import peerjs_serve
app = Flask(__name__)
@app.get("/")
def index() -> str:
return "hello from flask"
def run_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.