For Developers¶
This page explains how the pgwidgets-js remote interface works under the hood and is aimed at anyone implementing a new language binding (Ruby, Rust, Go, etc.) or a new transport. It is intentionally protocol-level: the JavaScript side is the authoritative implementation and this page describes the contract any other side must satisfy.
Read this together with:
pgwidgets_js/static/modules/RemoteInterface.js— the browser-side protocol handler (single file, ~840 lines).pgwidgets_js/defs.py— the canonical widget definitions (constructor args, methods, callbacks). Every binding generator starts here.pgwidgets-python/pgwidgets/sync/application.pyandasync_/application.py— the reference server implementation in Python. The reconstruction code is the largest piece by far.
Architecture¶
The browser is the renderer; it knows nothing about the application. All widget identity, state, and event routing live on the server side.
┌────────────────────────────┐ ┌──────────────────────────────┐
│ Server (your binding) │ │ Browser (pgwidgets-js) │
│ │ │ │
│ Widget objects │ WS │ RemoteInterface │
│ ──────────── │ ──────► │ ──────────── │
│ - mirror tree of children │ JSON │ - Callback._registry │
│ - cache state per widget │ ◄────── │ (wid → widget instance) │
│ - cache replay calls │ │ - DOM widgets │
│ - cache user callbacks │ │ │
│ │ │ No app state. │
└────────────────────────────┘ └──────────────────────────────┘
The transport is one WebSocket connection. Messages are individual JSON
objects, JSON arrays (batch), or raw binary frames (for image payloads).
Each request carries an id and gets exactly one response with the
same id; the server side is expected to await results when a return
value matters.
Widget identity is a single integer, the wid. Wids are allocated by
the server (so it can build widget trees before any browser is
connected). The browser uses the wid as the key in
Callback._registry and never assigns its own wids in normal
operation. The one exception is widgets created inside a method call
(e.g. mdi.add_subwindow(...) returns a new MDISubWindow); these
get auto-allocated wids on the browser side that the server then learns
about from the call result.
Message Protocol¶
All control messages are JSON. The protocol is small and largely
symmetric: {type, id, ...payload}.
Server → Browser¶
{type: "init", id}First message after the WebSocket connects. The browser destroys any existing widgets, clears its registry, and answers with a
resultthat includes the previoussession_idandtoken(if any) read from the URL orsessionStorage. This is how reconnection finds the right session.{type: "session-info", session_id, token}Sent after the server has decided which session this browser belongs to. The browser stores the credentials in
sessionStorageand rewrites its own URL so the link is bookmarkable. There is no response.{type: "create", id, wid, class, args}Instantiate
classwithargsand register it underwid. The args are resolved recursively: any{__wid__: N}is replaced with the widget at widNfrom the registry, so constructor arguments can be other widgets. Reply:{type: "result", id, wid, next_wid}.{type: "call", id, wid, method, args, silent?}Invoke
widget[method](...args).silent: trueis used during cross-browser sync — the browser still executes the call but suppresses outgoing callback events so the change does not echo back. Reply:{type: "result", id, value?}.{type: "binary-call", id, wid, method, args}(followed by a raw binary WebSocket frame)Identical to
callexcept the binary frame becomes the first argument to the method. Used for small image bytes (JPEG/PNG etc.) so the payload isn’t base64-inflated through JSON. Order matters: the JSON header is queued FIFO; the next binary frame is paired with the head of the queue.{type: "binary-call-chunked", id, wid, method, args, transfer_id, num_chunks}(announce; followed by N binary-chunk pairs)Opens a chunked transfer for payloads too large to ship in one frame. The announce reserves a
transfer_id; the receiver accumulates exactlynum_chunkspairedbinary-chunkmessages and then dispatcheswidget[method](payload, ...args). Optional fieldsshape(array of ints) anddtype(one of"uint8","uint16","uint32","int8","int16","int32","float32","float64") promote the delivered payload from a rawArrayBufferto a typed array of that dtype. See Chunked binary transport below.{type: "binary-chunk", transfer_id, chunk_index, num_chunks, encoding, [file_index, file_count, data]}(one chunk)One chunk of an in-flight transfer.
encodingis per-chunk:"binary"(default): the next WebSocket frame is the chunk’s raw bytes. Zero protocol overhead."base64": thedatafield on this JSON message is the chunk, base64-encoded. No following binary frame. Useful when the transport can’t carry raw binary cleanly.
The optional
file_index/file_countfields scope a chunk to one file within a multi-file upload; without them the chunks belong to a single payload.{type: "listen", id, wid, action}Subscribe to a callback. The browser wires up a forwarder on the widget that will emit
{type: "callback", wid, action, args}back to the server every time the callback fires (with the dedup and suppression rules described under Callbacks below).{type: "unlisten", id, wid, action}Remove the forwarder.
{type: "reconstruct-start", id, next_wid}and{type: "reconstruct-end", id}Brackets around a full UI replay; see Reconstruction below.
[ msg, msg, ... ]A batch. The browser dispatches each message and replies with an array of result objects in the same order.
Browser → Server¶
{type: "result", id, value?, wid?, next_wid?, session_id?, token?}Response to any request.
valueis the method’s return value (serialized: widget instances become{__wid__, __class__}).next_widis the current value of the browser-side auto-allocation counter, which lets the server know what wids it can safely use without colliding with a previous auto-allocation.{type: "error", id, error}Failure response.
{type: "callback", wid, action, args}A registered callback fired. Args are serialized the same way as return values (widget refs become
{__wid__}).{type: "viewport", width, height}Sent at session-info time and on every window resize, so the server can answer
get_screen_size()locally without a round-trip.{type: "binary-chunk", wid, transfer_id, file_index, file_count, chunk_index, num_chunks, encoding}(followed by a raw binary frame, unlessencoding == "base64")Same shape as the server-side
binary-chunk; used by the browser to upload dropped files / picker selections. See File uploads below.
Serialization Rules¶
Widget references in args/return values are encoded as objects with a
__wid__key (return values also include__class__so the receiver can decide which language-side class to wrap with). The reverse mapping happens on both sides:Browser
_resolveArgswalks the args tree and replaces{__wid__: N}withCallback._registry.get(N).Server-side bindings should walk the same way and substitute their own widget objects.
Plain values (numbers, strings, booleans, null, lists, dicts) pass through JSON.
Tuples vs lists: JSON has no tuples. Wherever the JS side declares a multi-argument setter like
"set_position": ["x", "y"], the binding is free to expose it as one method taking two args, or as one method taking a 2-tuple/array. The server-side cache should normalize to a tuple-shaped value (the Python implementation stores a tuple inwidget._state[key]) so reconstruction can re-spread it asmethod(*tuple).Binary payloads travel out of band. Build the JSON header describing the call (
binary-callfor one frame,binary-call-chunked+ Nbinary-chunkfor multi-frame transfers), send it, then send the raw binary frame(s). Receivers pair them by FIFO order on a single connection;transfer_idin chunked transfers groups frames belonging to the same payload. Do not interleave other binary frames between a chunk header and its payload.
Widget Definitions: defs.py¶
Every binding generator reads pgwidgets_js/defs.py. It is plain
Python data and intentionally not version-locked to any language:
WIDGETS = {
"Button": {
"base": "widget",
"args": ["text"],
"options": ["icon_url", "iconsize", "toggle"],
"methods": {
"set_text": ["text"],
"get_text": [],
"set_icon": ["url", "iconsize"],
# ...
},
"callbacks": ["activated"],
},
# ...
}
For each widget the entry tells you:
base"widget"(visual, paintable, has a DOM element) or"container"(a widget that holds children) or"callback"(a non-visual object likeTimerthat still gets a wid and participates in the registry but has no DOM box). The string"widget"and"container"imply visual;"callback"does not. This matters for which auto- sync listeners (resize,move) the binding should register passively — see State tracking below.args/optionsConstructor signature.
argsare positional.optionsare passed as the last positional argument, a plain JS object. The generator typically exposes both as Python kwargs.methodsMethod name → list of parameter names (no
self). The parameter names matter because the binding turns them into keyword-able arguments. The list is the canonical arg order to send over the wire.callbacksWidget-specific callback action names. Every visual widget also supports the universal
resizecallback, fired as{width, height}whenever the DOM box changes size, andmap, fired once when the element first reaches a non-zero visible box.
A binding must also classify each method into a category so it knows
how to wrap it. The Python reference implementation does this in
pgwidgets/method_types.py and a binding will want to mirror those
categories (or import the same tables — they are plain Python). The
categories are:
SETTER —
set_Xstyle. Sendscallto browser; also caches the value in the local widget’s state map under keyX. Returns nothing useful.GETTER —
get_Xstyle. Returns from the local state map without a round-trip. The state was populated by a matching setter or by an auto-sync callback (see below).ACTION — fire-and-forget side effect (
play,scroll_to,set_focus). No state cache.REPLAY action — a factory call like
add_action,add_separator,add_name. These are actions that create child widgets or affect tree structure; the binding records them in a per-widget_replay_callslist so they can be re-issued during reconstruction.CHILD —
add_widget,set_widget,insert_widget, etc. Tracks parent/child relationships in the local tree.JS_ONLY — round-trip query that isn’t cached (
get_paused,get_column_countetc., when there is no corresponding setter).
Constructing Widgets¶
Creating a widget is two operations:
The server side allocates the next wid (its own counter, starting at 1 and incrementing). It stores a widget object locally with that wid.
It sends
{type: "create", id, wid, class, args}. The browser constructs the JS class, registers it underwid, and responds with{result, wid, next_wid}.
The next_wid in the response is the JS side’s internal counter; the
binding must keep its allocator above that number to avoid collisions
with widgets the browser creates on its own (e.g. MDISubWindow
instances returned from mdi.add_subwindow). A safe rule is: after
every result carrying next_wid, set the server-side allocator
to max(current, next_wid).
Constructor args that reference other widgets are passed as
{__wid__: N} — same encoding as everywhere else.
Some widgets are factory-creators: toolbar.add_action(opts),
menu.add_name("File"). The browser-side method returns a new
widget instance; the response carries its auto-allocated wid in
value: {__wid__: N, __class__: "ToolBarAction"}. The binding
should wrap that into a local widget object and start tracking its
state. Until then, the binding may use a proxy — a placeholder
widget with a server-allocated wid that buffers callbacks and tracked
state. When the real widget is created, _transfer_proxy in the
Python reference replays all buffered state and callbacks onto the
real widget, then repoints the proxy’s wid to the new one.
Callbacks¶
Subscribing¶
To receive a callback the binding sends {type: "listen", wid,
action}. The browser wires up a forwarder via add_callback on
the JS widget. From then on, every time the callback fires, the
browser sends back {type: "callback", wid, action, args}.
Idempotency¶
A duplicate listen for the same wid:action is a no-op in normal
operation; during reconstruction it replaces the existing listener
(necessary because the JS widget instance is fresh).
Suppression¶
Two flags on RemoteInterface gate outgoing callbacks:
_syncing— set while asilentcall is executing. Used for multi-browser sync so a state change pushed to peers doesn’t echo back._reconstructing— set betweenreconstruct-startandreconstruct-end. Suppresses all callbacks exceptmap, which is a one-shot lifecycle event tied to the widget first becoming visible; missing it would leave the user’s map handler permanently un-fired.
Auto-sync callbacks¶
A binding should subscribe to two callbacks on every visual widget, not because the user asked, but because they back the local state cache:
resize— captures_state["size"] = (w, h)soget_size()returns the latest layout-determined size without a round-trip.move— captures_state["position"] = (x, y)where supported.
These are passive subscriptions: the binding listens, captures, and
suppresses replay. They are distinct from auto-sync subscriptions
which actively push state to peers and replay on reconstruction (e.g.
resize on widgets whose definition has the resizable option).
See State Tracking below.
State Tracking¶
The server is the source of truth, so for every widget it must
maintain enough state to recreate the widget from scratch. The Python
reference stores this in widget._state (dict, keyed by the same
strings as method names without the set_ prefix).
For each setter call widget.set_X(v):
Send
{type: "call", wid, method: "set_X", args: [v]}to the browser.Store
widget._state["X"] = v. If the setter takes multiple args (e.g.set_position(x, y)), store the tuple(x, y).Mark
Xas user-set (in the Python reference,widget._user_set_state.add("X")). This matters during reconstruction so layout-driven values aren’t confused with user-set ones.
For each fixed-value setter (show → visible=True, hide →
visible=False), store the fixed value under the corresponding key.
For each replay action (add_separator, add_action, …), append
(method, args, returned_widget, seq) to widget._replay_calls.
seq is a monotonically-increasing counter that interleaves with
widget._children insertions, so during reconstruction the two
streams can be re-interleaved in the original order. (Without that,
add_widget(a); add_separator(); add_widget(b) would re-emerge as
[a, b, sep] instead of [a, sep, b].)
For each callback that arrives, run the auto-sync logic:
resize→ store_state["size"] = (width, height).move→ store_state["position"] = (x, y).
Capture is unconditional (for getters). Replay is conditional: only replay these keys on reconstruction if the user explicitly set them, OR the widget opted into active sync. See Reconstruction below.
For widget-specific syncs (Slider activated → value,
ComboBox activated → index, etc.), see
WIDGET_CALLBACK_SYNC in method_types.py.
Reconstruction¶
Reconstruction is the heart of the design. The browser is treated as disposable: any time a page is refreshed, navigates away, or the WebSocket reconnects, the server replays every widget it knows about so the UI reappears in its current state. The user’s Python code keeps running and never sees the disconnect.
What triggers it¶
A browser reconnects with a known
(session_id, token)(sent in theinitack).A new browser joins the session URL (
?session=...&token=...).The server explicitly calls
session.reconstruct()(rare).
The walk¶
The reference reconstruction loop is essentially this (paraphrased
from sync/application.py):
def reconstruct(self):
self._reconstructing = True
self._send({"type": "reconstruct-start",
"id": ..., "next_wid": self._next_wid})
# 1. Walk widget tree, reconstruct each widget.
for widget in self.walk_widget_tree():
self._reconstruct_widget(widget)
# 2. Deferred state: keys like 'visible' that depend on the
# full tree being assembled (Splitter sizes etc.).
for widget in self.walk_widget_tree():
self._replay_deferred_state(widget)
self._send({"type": "reconstruct-end", "id": ...})
self._reconstructing = False
_reconstruct_widget for a single widget performs the following
steps in order. Each one matters; getting them out of order produces
subtle visual bugs.
- 1. Create the widget.
{type: "create", wid, class, args}whereargsare the original constructor arguments (cached inwidget._constructor_args/_constructor_options). Any widget references embedded in those args (e.g. aLabel’smenuoption pointing at aMenu) must be reconstructed first.- 2a. Replay item lists.
ComboBoxitems, etc. — anything tracked viaITEM_LIST_CONFIG. These must come before any state that indexes into them (set_index).- 2b. Replay state that changed after construction.
Iterate
widget._state. For each key, decide whether to replay:Skip keys already supplied to the constructor with the same value.
Skip keys managed by child methods (children handled in step 3).
Skip fixed-value keys (
visible); defer to step 4.Skip post-children keys (
Splittersizes,TabWidgetindex,_collapsed_paths, …).Skip keys starting with
_(private bookkeeping).Skip auto-sync keys that came in passively. This is the subtle rule: if the key has a matching state-sync action (
size→resize,position→move) and the user didn’t explicitly set it AND the widget didn’t opt into active sync, the value was captured purely from a layout-driven resize callback. Replaying it would pin the widget to pixel dimensions and override flex/expanding layout. Skip.For binary state (e.g.
set_binary_imagepayloads), replay via the binary transport, not embedded base64 in JSON.Otherwise: send
{type: "call", wid, method: "set_X", args}. If the value is a tuple it unpacks into multiple args.
- 3. Attach children and replay factory calls (interleaved).
widget._childrenandwidget._replay_callseach carry a seq number; merge them in seq order and replay each. For eachadd_widget/set_widget, ensure the child has been reconstructed first (recursion guarded byself._reconstructed_wids).- 4. Re-register callbacks.
Iterate
widget._registered_callbacks(the user’s callbacks) and re-sendlistenmessages, since the browser-side registry was cleared byreconstruct-start. Same for auto-sync and passive-sync actions.- 5. Deferred state (outer loop, after the whole tree is built).
Splitter sizes (need panes to exist), TabWidget index (needs tabs), tree expand/collapse paths, fix-value visibility (
show/hide), etc.- 6. Backstops at ``reconstruct-end``.
The browser, on receiving
reconstruct-end, schedules tworequestAnimationFrameticks:Tick 1: visibility-aware re-fire of
mapfor every widget, plus a syntheticresizewith the final laid-out size. The mid-reconstructionresizeevents were suppressed on the Python side (they’re state-replay echoes), so this is the widget’s first chance to learn its real final size.Tick 2: force-fire
mapfor any widget that’s still unmapped, regardless of visibility. Catches widgets in detached subtrees (e.g. inactiveTabWidgetpages).
Factory-replay edge cases¶
When the user calls a factory like toolbar.add_action(opts), the
return is a new widget. The server stored a proxy. During
reconstruction:
_replay_one_factory_callreplays the factory call, gets back the real new wid from the browser._transfer_proxycopies callbacks, class-specific synced state, and any other state from the proxy onto the new widget. It applies the same auto-sync skip rule as step 2b — passively- captured size/position is not replayed.The proxy’s wid is repointed to the new wid; the binding’s wid map is updated so user code holding the proxy reference keeps working.
If the proxy accumulated its own factory sub-calls (e.g.
menu.add_name("..")), recurse.
What not to replay¶
A short checklist of state that is captured for getter support but must not be replayed on reconstruction:
size/positionof any widget whose definition doesn’t declareresizableand that the user never explicitly resized.Anything starting with
_(private bookkeeping).Anything in
_FIXED_STATE_KEYS(handled by the deferred loop).Anything in
_CHILD_STATE_KEYS(handled by the child loop).
Multi-Browser Sync¶
A session can have multiple connected browsers. When one browser fires
a state-syncing callback (e.g. resize on a resizable widget,
activated on a Slider), the server applies the change locally,
then pushes it to all the other browsers as a call with
"silent": true. The peers execute the call but their
RemoteInterface sets _syncing = true for the duration, so the
resulting callback does not echo back.
This is a one-line mechanism on the protocol side. The work is in
deciding which actions to forward; see STATE_SYNC_CALLBACKS and
WIDGET_CALLBACK_SYNC in method_types.py for the policy table.
Chunked Binary Transport¶
Used in both directions for payloads too large to ship in a single
WebSocket frame, or when the sender wants to interleave control
messages with a long transfer. The same binary-chunk envelope
is symmetric: server → browser (for binary-call-chunked —
e.g. an Image’s pixel buffer) and browser → server (for file
uploads).
Wire layout:
server→browser (binary-call-chunked):
→ {"type": "binary-call-chunked",
"id": 42, "wid": 7, "method": "load_buffer",
"args": [[2048, 2048], {...}],
"transfer_id": 17, "num_chunks": 32,
"shape": [2048, 2048, 4], "dtype": "uint8"} ← optional
→ {"type": "binary-chunk", "transfer_id": 17,
"chunk_index": 0, "num_chunks": 32, "encoding": "binary"}
→ <raw 524288-byte binary frame>
→ {"type": "binary-chunk", ..., "chunk_index": 1, ...}
→ <raw 524288-byte binary frame>
⋮
→ {"type": "binary-chunk", ..., "chunk_index": 31, ...}
→ <raw <=524288-byte binary frame>
browser→server (file upload):
→ {"type": "callback", "wid": 7, "action": "drop-end",
"args": [{"transfer_id": 17,
"files": [{"name": "...", "size": ..., "type": ...}],
...}]}
→ {"type": "binary-chunk", "wid": 7,
"transfer_id": 17, "file_index": 0, "file_count": 1,
"chunk_index": 0, "num_chunks": N, "encoding": "binary"}
→ <raw binary frame>
⋮
Per-chunk encoding lets a sender choose "binary" (next
frame is the chunk) or "base64" (the chunk rides in this JSON
message’s data field, no following binary frame). Default is
"binary"; both pgwidgets-js and pgwidgets-python only emit the
binary form, but receivers must accept either.
When the announce includes shape and dtype, the receiver
constructs a typed array of that dtype (Uint8Array,
Float32Array, …) before dispatch instead of a raw
ArrayBuffer. The supported dtypes mirror pgwidgets-python’s
pgwidgets.Buffer (uint8 / uint16 / uint32 /
int8 / int16 / int32 / float32 / float64).
Chunks may arrive out-of-order in principle (a single TCP/WS
stream preserves order in practice, but receivers should still
write into indexed slots by chunk_index, not append). The
transfer completes when every slot is filled; the receiver
reassembles, dispatches, and discards the transfer.
A binding can opt to ignore the chunked path and only support
single-frame binary-call if it doesn’t need large payloads or
file uploads.
Session Identity and Reconnection¶
The session ID is a server-side concept. When a browser first
connects with no credentials, the server allocates a new session and
sends {type: "session-info", session_id, token}. The browser
stores those in sessionStorage and rewrites its URL to
?session=ID&token=TOKEN.
On reconnect (the WebSocket dropped, the page was refreshed, the user
followed a saved link), the browser sends the credentials back in the
init ack: {type: "result", id, session_id, token}. The server
either:
recognizes
(session_id, token), attaches this WebSocket to the existing session, and immediately starts a reconstruction; orrejects the credentials with a 4xxx close code (the browser shows a “Connection rejected” page and clears its stored credentials).
The URL is preferred over sessionStorage because it lets a user
paste a link to share a session.
Things That Will Bite You¶
In rough order of how easy they are to miss:
Wid collisions and classMap classes that don’t extend Callback. Advancing your allocator past
next_wid(from everyresult, not just the latest) is necessary but not sufficient. If the receiving side has a class inclassMapwhose constructor doesn’t callsuper()(e.g. a plain JS class layered on top of pgwidgets-js — gingajs’sControlleris one), then_nextIddoesn’t advance during that widget’s construction. The next widget’ssuper()will then auto-assign a wid that the previous Python-allocated widget is already sitting at, silently overwriting it in the registry. Symptom: subsequentadd_widget/listencalls fail with “Unknown widget id”. Mitigations in the reference implementation (do at least one):_handleCreatebumpsCallback._nextIdpastmsg.widbefore callingnew cls(...), so the auto-wid is guaranteed to land in unoccupied territory.The
Callbackconstructor skips occupied registry slots when allocating, as a defensive backstop.
A binding that only ever uses classes which extend
Callbackis safe without either mitigation, but it’s worth having both.Tuple vs single-arg setters.
set_position(x, y)stores a tuple butset_text(s)stores a string. Your binding must normalize so reconstruction can re-spread tuples as multiple args and singles as one arg.Suppressing callbacks. Forget to set
_syncing/_reconstructingand you’ll get echo loops or duplicate state updates.The ``map`` exception. Don’t apply
_reconstructingsuppression tomap. The observers fire once and disconnect; if you swallowmapthe user’s handler is dead until something else re-fires it.Replaying passively-captured size. This is the silent killer. Capture must be unconditional (for getters), replay must be conditional (or you pin every widget to whatever pixel size the layout happened to settle at on capture). The same guard applies in both the top-level state replay AND
_transfer_proxy.Order in ``_replay_interleaved``.
_childrenand_replay_callscarry their own sequence numbers for exactly this reason. Replay them merged in seq order.Constructor args that are widgets. Reconstruct them first (
_ensure_reconstructed) before sending the parent’screate.Binary frames are FIFO-paired with headers. A single
binary-callJSON header followed by anything other than the expected binary frame breaks the pairing for every following binary call on that connection. Same applies to chunkedbinary-chunkheaders withencoding == "binary".Atomic emission of a chunked transfer. On the sender side, all chunks for one transfer should ship under a single coroutine / lock on the WebSocket so other binary calls can’t splice frames in between. Receivers use
transfer_idto disambiguate, so out-of-band interleaving is technically OK — but the reference implementation chooses atomic emission anyway to keep failure modes simpler.Chunk reassembly by index, not append. WebSocket frames over a single TCP stream do arrive in order, so a naive append-based reassembler will work in practice — but
chunk_indexis on every chunk header for a reason. Writing chunks into a slotted array indexed bychunk_indexis one extra line and bullet- proofs you against any future transport that doesn’t preserve order.Mixing encodings within a transfer.
"binary"and"base64"chunks are interchangeable per the protocol, but doing both in one transfer is surprising. Pick one per transfer and stay consistent.Unknown dtypes degrade to raw ArrayBuffer. If an announce carries
dtype: "float16"(or anything else not in the standard set), the JS side has no matching TypedArray constructor and logs a warning, then delivers the rawArrayBufferinstead. Stick to the documented dtypes (uint8/uint16/uint32/int8/int16/int32/float32/float64).API shape: ``f.data`` is bytes. As of the binary-transport unification,
payload.files[i].datain drop / FileDialog callbacks is raw bytes (ArrayBufferon the JS side,byteson the Python side) — not a"data:<mime>;base64,…"string. Old code that didbase64.b64decode(data.split(",", 1)[1])is now wrong; just usedatadirectly.
Where to Look in the Code¶
If you want to read one file front to back, start with
RemoteInterface.js. It is intentionally self-contained.
For the server-side dance, the Python reference is in two paired implementations (sync and async share roughly identical structure):
pgwidgets/method_types.pyTables:
ACTION_METHODS,REPLAY_METHODS,CHILD_METHODS,SPECIAL_SETTERS,SPECIAL_GETTERS,FIXED_SETTERS,STATE_SYNC_CALLBACKS,STATE_SYNC_REQUIRES_OPTION,WIDGET_CALLBACK_SYNC,STATE_DEFAULTS,STATE_KEY_DEFAULTS,ITEM_LIST_CONFIG,POST_CHILDREN_STATE_KEYS,CLEAR_RESETS,FACTORY_RETURN_TYPES. Theclassify_methodfunction turns a method name plus its parameter list into one ofSETTER / GETTER / ACTION / CHILD / JS_ONLY.pgwidgets/sync/widget.py(andasync_/widget.py)The metaclass / class generator. Walks each entry in
WIDGETSfromdefs.pyand synthesizes one Python class per JS widget, with methods generated by_make_setter/_make_getter/_make_action/_make_child_methodetc. This is the file that gives a binding its “feel” — its decisions here are what user code looks like.pgwidgets/sync/application.py(andasync_/application.py)Session, transport, reconstruction.
_reconstruct_widget,_replay_interleaved,_transfer_proxy, and_reregister_callbacksare the four methods worth reading carefully if reconstruction is what you need to implement.
The protocol itself is small. The categorization tables are where the
ergonomics live; copying them and porting classify_method to your
language is the bulk of the work for a new binding generator.