Architecture: the viewer channel#
This page explains how a notebook viewer and Python talk to each other. If you only want something that works, start with Quickstart: minimal round-trip (select → highlight).
Design goals#
One protocol for both directions, over two transports: a notebook widget’s comm (what
show_anndata()andWidgetuse) and a WebSocket to a local server (a browser-tabAnnDataViewer, the R package outside a notebook). Everything above the transport is one implementation.No port and no proxy in a notebook: the widget reaches the kernel through the notebook’s own connection, so a remote kernel, an HTTPS hub, Colab, VS Code and Databricks need no setup.
Data and events stay between the browser and the kernel: never a cloud relay. The app itself is loaded from
https://www.cellucid.com(or the copy named byweb_source_url); the dataset never goes there.Credentials never appear in an address a proxy could log: the widget hands them to the page in a message.
Failures are diagnosable (
viewer.debug_connection()).
System diagram: the notebook widget#
┌──────────────────────────── Notebook kernel (Python or R) ───────────────────┐
│ cellucid.Widget (anywidget) / cellucid_view() in IRkernel │
│ "fetch" {method, path, Range} → the dataset's documents and bytes │
│ (AnnDataServer.answer_request / the R server's routes, no socket) │
│ "open"/"text"/"binary"/"close" → the viewer channel, the same code a │
│ WebSocket runs (serve_viewer_channel / .on_ws_open) │
└───────────────────────────────────────────▲──────────────────────────────────┘
│ the notebook's connection (comm
│ messages, binary buffers; the
│ page's frames to IRkernel as
│ base64), between cells
┌───────────────────────────────────────────▼──────────────────────────────────┐
│ Widget front end (widget.js, in the cell's output) │
│ <iframe src="https://www.cellucid.com/?viewer=embedded"> │
│ accepts "ready" only from that frame and origin, answers "connect" │
│ {viewerId, viewerToken} + a MessagePort, posted to that origin only │
└───────────────────────────────────────────▲──────────────────────────────────┘
│ MessagePort (no other window can post to it)
┌───────────────────────────────────────────▼──────────────────────────────────┐
│ The Cellucid web app (ViewerHostDataSource, data/viewer-embed.js) │
│ viewer channel over a WebSocket-shaped PortSocket │
│ requests to https://host.cellucid.invalid/… answered over the port, in │
│ the page and in its reader workers (formats/io/host-fetch.js) │
└──────────────────────────────────────────────────────────────────────────────┘
The widget’s messages name the output they belong to, so one widget shown in
two outputs is two connections; as with a reloaded page, the newer one takes
the viewer over and the older one is told so (4409). Requests that arrive
together go to the kernel as one message, at once (the page’s reader bounds
how many it has under way, as with a web host); the Python kernel answers
each on a thread of its own, as its HTTP server does, so a request that takes
long to make (a chunk of a matrix the layout has not reached yet, made from
its progress and the object) holds back no other, and R answers them in turn. An R kernel has at
most two such messages at once (the widget’s _fetch_messages trait): the
requests asked while they are answered gather into the next message, since
IRkernel spends about 1.7 ms of its own on every message it reads. Bodies travel as
binary buffers, from Python and R alike (IRkernel’s own send writes none, so
the R package makes those messages with the kernel’s reply and wire
functions);
the page’s binary frames to an R kernel travel as base64, because IRkernel’s
reader drops the buffers of a message it receives. The kernel handles them
between cells: see Supported environments matrix for what that
means while a cell runs.
One limit of the notebook server shapes the answers: Jupyter Server stops
forwarding a kernel’s messages to the browser (it drops them, it does not
delay them) while they exceed 1,000 a second over a 3-second window
(ServerApp.iopub_msg_rate_limit, rate_limit_window; comm messages
count, their bytes do not). So answers ready together share a message (at
most 8 MiB of bodies each), and the Python kernel sends at most one message
of answers every 2 ms, half that rate; R sends one message (or a few, by
size) for each message of requests.
System diagram: a browser tab#
┌──────────────────────────── Python or R ─────────────────────────────────────┐
│ AnnDataViewer ── AnnDataServer (one per viewer) │
│ GET / the viewer's web build │
│ GET /_cellucid/datasets the dataset and its launch choices │
│ GET /_cellucid/anndata/... the data's bytes (Range, sendfile) │
│ GET /_cellucid/protocol where the channel is, what it carries │
│ GET /_cellucid/ws ────────── the viewer channel (one WebSocket) │
└───────────────────────────────────────────┬──────────────────────────────────┘
│ ws:// (wss:// behind HTTPS)
┌───────────────────────────────────────────▼──────────────────────────────────┐
│ Browser tab: <server>/?viewer=true&viewerId=…&viewerToken=…&anndata=true │
│ The Cellucid web app (ViewerHostDataSource) opens the channel: │
│ → hello {viewerId, viewerToken} ← welcome │
│ ← commands (text and binary frames) → events (text and binary frames) │
└──────────────────────────────────────────────────────────────────────────────┘
The page reaches the server at the address it was loaded from, path prefix included, so a proxy that carries the page carries the channel too. The proxy must pass WebSockets.
Opening the channel#
The steps are the same over the widget; there the protocol document is a
fetch answered by the kernel, the socket is a port socket, and the Host
and Origin checks of step 2 are the widget’s own: it accepts the page’s
ready only from its frame and the app’s origin, and posts the credentials to
that origin only.
The viewer reads
GET /_cellucid/protocol:{"channel": "_cellucid/ws", "commands": [...], "binaryCommands": [...], "events": [...]}. A server withoutchannel, or withoutbinaryCommands(it sends the cells of a highlight as JSON text), is an oldercellucid; the viewer says to upgrade the package.It opens a WebSocket at
<serverUrl>/_cellucid/ws. The server checks theHostheader (421 otherwise, as for every route) and theOriginheader: the page must be this server’s own host, a loopback name, or a name inallowed_hosts(403 otherwise).The first frame is
{"type": "hello", "viewerId": …, "viewerToken": …}. The server compares the token in constant time and answers{"type": "welcome"}, or closes with 4401.From then on the socket is that viewer’s; no later frame carries credentials.
Commands (Python → viewer)#
One JSON text frame each, with exactly the keys of its type (
_wire._COMMAND_MESSAGE_FIELDS); see Python → frontend actions.An
actionwhose parameters hold arrays (cells, a column’s values) is one binary frame: its header (_wire._BINARY_COMMAND_HEADER_FIELDS: the parameters without the arrays, and where each array goes), padded to a multiple of 4 bytes, then each array’s elements, little-endian, which the viewer reads in place. Two million cells are 8 MB, where the same cells as JSON text were 14 MB.Every
actionnames the notebook turn it belongs to:turn(its id) andturnLabel(what it was asked to do), both null outsidewith viewer.turn(...).load_session(viewer.load_session(...)) is always one binary frame: its header (the request and the file’s name), padded the same way, then the.cellucid-sessionfile.A command sent before the viewer is connected (the cell output still loading, the page reconnecting) waits in the viewer’s outbox and is sent, in order, the moment it connects. Nothing is sent twice.
viewer.send_message(...)returnsTruewhen the command was sent now,Falsewhen it is waiting, and raises afterviewer.stop().
Events (viewer → Python)#
Text frames for
hover,click,ready,pong,debug_snapshot,command_error,data_mapping,color_by,result_removed,results,history_changedandsession_loaded: exactly the keys of their type, and no viewer id.Binary frames for
selection,result,action_resultandsession_bundle: a little-endian uint32 header length, a UTF-8 JSON header ({"type": "selection", "source": …}, a result’s header,{"type": "action_result", "requestId": …, "step": …}, or{"type": "session_bundle", "requestId": …}), then the body. A result’s body is its cells and then its values (Round trip (results back from the viewer, columns and gene sets into it)). A selection’s body is the cell indices as little-endian uint32, strictly ascending; an action’s answer is its outcome as UTF-8 JSON; a session’s is the.cellucid-sessionfile (format 4), streamed to a temporary file as it arrives.The viewer sends only the event types
/_cellucid/protocollists.Each event is checked (
_wire._require_inbound_jupyter_event) and then delivered to the viewer’s hooks on the socket’s thread, one at a time and in order. A frame that breaks the rules closes the channel (1008) before any hook runs. A hook that raises is logged (loggercellucid.viewer_channel, “A viewer event hook failed”) and the channel stays open. The event was recorded inviewer.stateand handed towait_for_eventbefore any hook ran, and the hooks after the failing one still run.
Lifetime#
Close code |
Meaning |
What the viewer does |
|---|---|---|
1001 |
the viewer or its server was stopped ( |
freezes the last view and says so |
4401 |
unknown viewer or wrong token (a page left over from before a kernel restart) |
freezes the view; run the cell again |
4409 |
the same viewer opened a newer channel (its page reloaded, or the output is shown twice) |
freezes this copy and says the viewer is open elsewhere; it does not take the channel back |
1008 |
a frame broke the protocol |
freezes the view and names the reason |
anything else |
the network or a proxy dropped the socket |
reopens once, at once; waiting events are sent in order (a newer hover replaces a waiting one) |
Every close the server makes is orderly (RFC 6455 §7.1.1): it sends the close frame, shuts its sending side, and reads and drops what the page still sends until the page closes its side, for at most 5 s, before it closes the socket. The page reads the code and reason it was sent, on every platform; a socket closed with unread input is reset instead, and on Windows the reset would discard the close frame (the page would see 1006).
The server sends a ping every 20 s, which the browser answers, so an idle channel survives proxies that close silent connections.
Nothing is polled. If a reopen fails, the viewer makes one health request: no answer means Python stopped; an answer means the server runs but the WebSocket cannot reach it, and the message names the proxy as the cause.
Viewer identity and routing#
Each viewer has its own viewerId and a secret viewerToken: a widget hands
them to its page in a message, a browser tab carries them in its address.
Python keeps a process-wide registry
viewerId → (token, callback, outbox, socket) (cellucid._viewer_channel).
After a kernel restart the registry is empty, so an old page’s hello is
refused (4401) and the old view freezes: run the cell again.
A browser tab’s address#
A browser-tab viewer’s page is
http://127.0.0.1:<port>/?viewer=true&viewerId=…&viewerToken=…&anndata=true
(viewer.viewer_url). When the browser cannot reach that address directly,
forward the port over SSH, or pass the browser-reachable address of a reverse
proxy with client_server_url= (such as
https://<hub>/user/<name>/proxy/<port>) and name the proxy’s host in
allowed_hosts=. The channel then runs at
wss://<hub>/user/<name>/proxy/<port>/_cellucid/ws. A notebook widget needs
none of this.
The web build#
A widget’s page is the app at web_source_url (https://www.cellucid.com by
default), loaded by the browser. A browser tab loads the app from its local
server, which establishes a verified generation from web_source_url before
it binds: every file is hash-checked on disk and published atomically.
Controls: web_cache_dir=, viewer.ensure_web_ui_cached(force=True),
viewer.clear_web_cache().
Where to look in the code#
Python channel:
cellucid-python/src/cellucid/_viewer_channel.py(handshake, outbox, events, session files) over_websocket.py(RFC 6455 framing).Python checks:
cellucid-python/src/cellucid/jupyter/_wire.py.Python viewer:
cellucid-python/src/cellucid/jupyter/_viewer.py.Web channel:
cellucid/assets/js/data/viewer-channel.jsandviewer-protocol.js; the bridge:data/viewer-host-source.js.The rules both sides follow:
cellucid/tests/fixtures/viewer-protocol.json(cellucid-python and cellucid-r keep byte-identical copies astests/fixtures/viewer_protocol.jsonandtests/testthat/fixtures/viewer_protocol.json).
Next steps#
Commands: Python → frontend actions
Events: Frontend → Python events
Security: Security model and Security: CORS, origins, and mixed content
Troubleshooting: Troubleshooting (hooks)