Multiple streams per server¶
One serve(w, h) hosts one framebuffer. But a single process often has several
things to show at once — different cameras or viewports of a simulation, a dashboard
of independent plots, a per-user view. Multiple streams let you host all of them
from one port, each an independent Display
a browser attaches to by URL path, discoverable over a small REST listing.
This is distinct from two things it is sometimes confused with:
- Multi-client (many viewers of one stream) already works — the push
Displayfans each frame out to every viewer with its own session and backpressure. Streams are the other axis: many independent framebuffers.
A stream is just "a Display plus its encoder config", and routing is purely
additive: sessions, encoders, backpressure, auth, and "still after settle" are all
unchanged.
The hub: serve_server()¶
import pdum.rfb as rfb
server = await rfb.serve_server(port=8765)
camera = server.add_stream("camera", 1280, 720) # ws://host:8765/camera
depth = server.add_stream("depth", 640, 480, has_h264=False) # ws://host:8765/depth
while running:
for ev in camera.poll_events() + depth.poll_events():
...
camera.publish(render_camera())
depth.publish(render_depth())
await asyncio.sleep(1 / 30)
await server.aclose() # stops the listener and disconnects every viewer
Each add_stream(name, w, h, **config) returns its own Display. Streams are
independent: per-stream resolution, bitrate, gpu, adaptive, still_after,
and authenticate. One can be a GPU H.264 stream and another a dependency-light
image stream. Add streams before or after the listener starts — clients reach a
stream at ws://host/<name> either way.
Keeping the one-liner: serve()¶
The single-stream serve(w, h) is unchanged and still returns a Display. Under the
hood it is now a hub with one "default" stream, reachable through display.server:
display = await rfb.serve(1280, 720) # the "default" stream
overview = display.server.add_stream("overview", 320, 240)
...
await display.aclose() # closing the returned Display tears down the whole hub
A browser that connects with no path (ws://host/) lands on "default", so
existing clients and RemoteFramebufferView({ url }) keep working untouched.
In the browser¶
Point the view's URL at the stream's path — nothing else changes:
new RemoteFramebufferView({ url: "ws://localhost:8765/camera", canvas });
new RemoteFramebufferView({ url: "ws://localhost:8765/depth", canvas: canvas2 });
Connecting to an unknown stream closes the socket with application code 4404.
REST: discover and inspect streams¶
The same port answers a small HTTP side channel:
| Route | Returns |
|---|---|
GET /health |
ok |
GET /streams |
[{name, width, height, fps, clients}, ...] |
GET /streams/<name>/metrics |
per-session metric snapshots for that stream |
For backward compatibility the single-stream routes (GET /metrics,
GET /recorded-events, GET /recorded-events/reset) act on the "default" stream
when one exists.
curl http://localhost:8765/streams
# [{"name": "camera", "width": 1280, "height": 720, "fps": 30, "clients": 2},
# {"name": "depth", "width": 640, "height": 480, "fps": 30, "clients": 0}]
Per-stream authorization¶
The AuthContext passed to a stream's
authenticate hook carries ctx.stream, so one hook can authorize differently per
stream (or you can pass a different hook to each add_stream):
async def authenticate(ctx):
user = verify(ctx.token)
if user is None:
return None
return user if user.may_view(ctx.stream) else None
server.add_stream("admin", 1280, 720, authenticate=authenticate)
server.add_stream("public", 1280, 720) # no auth
Lifecycle¶
serve_server()→ aServer;await server.aclose()stops the listener and disconnects every viewer of every stream.serve()→ the defaultDisplay;await display.aclose()tears down the whole hub (the one-liner contract). Useserve_server()when you want to manage the hub explicitly.server.port(anddisplay.port) report the bound port — handy withport=0, which picks a free one.