Docs · Protocol

Protocol

The messages a page and a panel exchange, what each one carries, and how the transports move them. This page is for contributors: the wire format is internal (@runespect/agent/protocol/internal) and changes with PROTOCOL_VERSION, which is 1. The code that defines it is packages/agent/src/protocol/internal.ts; a test (apps/playground/test/protocol-doc.test.ts) fails when a kind or a value marker is added there without a section here, or removed there and left here.

The envelope #

Every message is a plain JSON object. The extension relay and the HMR channel carry JSON, not structured clone, so nothing else crosses: values JSON cannot hold are encoded (see Values).

Field On Meaning
source every message always 'runespect'; how a listener on a shared channel finds ours
kind every message which message it is
from page to panel the page's id, new on every page load; stamped by the agent on all it sends
n page to panel one more than the last message this connection sent, from 1
to panel to page the page a command is meant for; a page drops a command addressed to another
__rsFrom window and HMR only the sending transport's own tag, so it can drop the copy the channel echoes back

Each kind goes one way, listed in TO_PANEL_KINDS and TO_PAGE_KINDS, and a side handles only the other direction's kinds. The window transport and the HMR broker hand a sender its own messages back, so a kind that one side both sent and handled would loop. The compiler rejects a kind that is in neither list.

A message without from is from a page that has no agent: the extension's Names only walk or the capture capture (see Other senders). A command without to is for whoever hears it, which is what the extension relies on: its service worker already routes by tab.

The panel keeps the last n per from, starting at the first it hears. A number skipped, or one heard before, means a message was lost or came out of order on the way: the panel records a gap (see the event log); the panel window also warns once per page in its console. A message heard before is dropped. A number skipped in the page the panel follows asks that page again with request and history-request (since 0), so the tree and the history match the page whatever was lost; with no tree within a second it asks again, three times at most; an ops that shows the gap is not applied, since it builds on what came before, while a message that stands on its own is. A page that connects again counts from 1.

Sequences #

Connecting #

A panel starts by asking every page who is there. Only a page asked by name (or one this transport already follows) starts reading its values and sends its tree; until then the agent stays quiet.

page (agent)panelpage (agent)panelfollows the pagethe app changeshello (on connect)requesthello {version, tier, url, title}history-request {since: 0}history {commits, truncated}request {to}tree {nodes}hellohistory-request {since: 0, to}history {commits, truncated}ops {ops, commit, t}

The hello on connect is for a panel that was open before the page loaded: its one request went out before the page existed. A reload is a new page with a new id. The page before it says bye, and the new one names it in replaces, so the panel follows the new page in its place; one whose bye was lost is pinged, and gone if it does not answer within a second. The extension's panel hears one tab, routed by tab, so there a page that replaces the followed page is followed at once: a page that is unloading cannot always post its bye to the content script.

Opening a value #

The tree carries values one level deep. To show more, the panel asks for the value at a path, and keeps the reply beside the tree the page sent (ui/held.ts).

page (agent)panelpage (agent)panelinspect {id, path: [symbol, ...steps], limit?}hydrate {id, path, value}

Editing a value #

page (agent)panelpage (agent)panelruns the setteredit {id, symbol, path, value}ops {ops, commit, t}

There is no reply of its own. The change comes back as the ops of the flush it caused, or not at all when the page refused the edit.

Picking an element #

page (agent)panelpage (agent)panelhover highlights, click selectspick {active: true}picked {id}

Escape answers picked with id: null. pick with active: false ends a pick without an answer.

Which page the panel follows #

The page's side is packages/agent/src/link.ts, the panel's is packages/ui/src/follow.ts, with sources.ts (the pages heard), panel-step.ts and numbering.ts (lost messages). Each is a function from a state and an event to a new state and what to do, so the tests play the situations below without a browser. apps/playground/test/simulator.test.ts plays pages and a panel on one bus, with messages lost on the way.

A page is in one of five modes: quiet (no panel has spoken to it; it said hello once), watched (a panel asked who is there; it says hello again when its url, title or visibility changes, and bye when it goes), following (a panel asked for its tree; as watched, plus ops for every flush), away (it said bye and says nothing more until it comes back from the back/forward cache) and ended (disconnected). The panel follows one page at a time, for one of three reasons: it was picked by the user, it is at the url the panel was opened for or remembers, or it was the first heard. The reason decides what may take the panel from it.

Situation The page sends The panel does Tests
The panel is open before the page loads hello on connect lists it; follows it if it is the first, or at the remembered url; asks it for its tree and history link.test.ts "says hello once at connect"; follow.test.ts "the first page heard is followed"
The page loaded before the panel opened hello, once the panel's request comes lists every page; follows one; the others stay quiet simulator.test.ts "pages loaded before the panel"
The user picks another page on request {to}: tree, hello; then ops follows it, remembers its url, asks it alone; what the page before it sends is not shown follow.test.ts "a pick moves the panel"; simulator.test.ts "a picked page keeps the panel"
The followed page reloads bye (in pagehide); the new page's hello names the old page in replaces follows the new page in its place; one row page-continuity-panel.test.ts "a reload says bye"; simulator.test.ts "a page reloaded under the panel"; page-continuity-agent.test.ts "a page that finds an id says it replaces that page"
A reload whose bye was lost the new page's hello with replaces pings the old page; if it is silent for 1 second (WAIT_MS), it is gone and the new page is followed page-continuity-panel.test.ts "a reload whose bye was lost"; simulator.test.ts "a reload whose bye is lost"
The extension's panel (one tab) the same, but the bye often never arrives follows a page that replaces the followed one at once, with no ping (oneTab) unit: page-continuity-panel.test.ts "a panel for one tab follows the page that replaces its page at once". Not covered in a browser: a manual pass in Chrome (see AGENTS.md)
bye and no new page within the wait bye the page is gone and another is chosen; a reload that arrives late takes the panel back, unless the user picked another page meanwhile page-continuity-panel.test.ts "bye and no new page in the wait", "a late reload does not take the panel back from a page the user picked"
The followed page navigates on the client, or its title changes hello again with the new url or title (once the page is watched) updates the row and the url it remembers; the panel stays link.test.ts "a new url or title says hello"; page-continuity-panel.test.ts "a known page retitling does not move the panel"
A tab is shown or hidden hello with visible updates the row. A page shown does not take the panel from a page that is alive. When no url is remembered and the panel took the first page while its tab was hidden, a page that arrives visible takes the panel page-continuity-agent.test.ts "the hello says whether the tab is visible"; page-continuity-panel.test.ts "a page shown or hidden does not move the panel", "a tab opened in front of the followed page does not take the panel"
The page goes into the back/forward cache, and comes back bye in pagehide; nothing more while hidden; on pageshow a hello with no replaces keeps the row while it waits; a page back under its own id takes the panel back link.test.ts "after bye a page says nothing more", "back from the back/forward cache"; page-continuity-panel.test.ts "a lost page back from the back/forward cache"
A tab is copied (two pages, one remembered id) the new page finds its own id and names no replaces the old page answers the ping and keeps the panel; both are listed page-continuity-agent.test.ts "a page that finds its own id names no page before it"; page-continuity-panel.test.ts "a copied tab"
A message from the page is lost every message carries n, one more than the last sees the gap; for the followed page, asks for its tree and its whole history again, up to 3 times (REPAIR_ATTEMPTS), waiting 1 second (WAIT_MS) for the answer each time; a gap from another page is only reported panel-step.test.ts "a gap in ops from the page it follows"; simulator.test.ts "an update lost on the way is noticed"
The panel's request by name is lost nothing sends it again until the page answers with its tree simulator.test.ts "a request by name that is lost is sent again"
The panel reconnects (a new transport to the same page) on request from a transport it already serves: tree again shows the new tree link.test.ts "a reconnecting panel gets its tree again". Not covered in a browser: the extension's port reconnecting after Chrome stops its service worker is a manual pass
The page is disconnected nothing more; stops watching, ends a pick, clears the highlight — link.test.ts "a disconnect stops everything"
A capture or the extension's Names only walk no bye, no replaces, and no answer to a ping pings it on its first hello like any page; nothing comes back, and the row stays, since there is no bye to wait for No test names this case. The rule is stated under ping below

A row that says "not covered in a browser" is covered by a unit test of the rule, not of the extension carrying it: no tool can select a DevTools panel, so the panel and panel-port hops cannot be automated.

Messages #

hello #

Page to panel. Who the page is and which version it speaks. Sent when the agent connects, as the answer to every request and ping, and, once a panel has asked, again whenever the page's url, title or visibility changes (an app that navigates on the client, followed through the Navigation API), and when it comes back from the back/forward cache.

Field Type Meaning
version number the page's PROTOCOL_VERSION; the panel warns when it differs from its own
tier 0 | 2 0 from the agent, 2 from the capture; absent means Names only
problem string? why a capture recorded nothing
url string? path and query, with secret-looking query values replaced by …
title string? the page's document.title
openInEditor false | 'vscode'? how Open in editor opens: absent through the dev server, 'vscode' as links, false off
agentVersion string? the agent's package version, such as 0.2.0
visible boolean? whether the tab is shown (document.visibilityState)
replaces string? the page before this one in the same tab, which a panel watched

A page a panel has spoken to (a request or a ping) is watched: it keeps its id in the tab's sessionStorage under __runespect_page, which survives a reload. The next page in the tab finds it, names it in its first hello as replaces, and is watched from the start. A tab no panel spoke to writes nothing. Storage that is missing or throws names no page before.

request #

Panel to page. No fields of its own. Without to it asks who is there, and every page answers with a hello. With to, or from a transport the page already serves (a reconnect), the page also starts following its flushes and sends a tree.

ping #

Panel to page, always with to; a page ignores one without it. A page with the agent answers with a hello and is watched from then on, but does not follow its flushes or send a tree. The capture and the extension's Names only walk do not answer: they say no bye or replaces either, so the panel has nothing to ask them.

bye #

Page to panel. No fields of its own. A watched page sends it in pagehide: a reload, a close, navigating away, or going into the back/forward cache.

tree #

Page to panel. nodes: SerializedNode[], the whole tree. Sent once per transport, as its first answer; after that the page sends ops. A panel keeps a tree it already has when an empty one arrives.

Node field Type Meaning
id number the node's id in this page
name string the component's name, or the route for a SvelteKit file
parent number | null null for a root
children number[] the children's ids, in order
snippets string[] names of the snippets the component declares
values Record<string, unknown> each reported value, encoded one level deep
writable string[] the values an edit may change
kinds Record<string, SymbolKind> per value: state, derived, prop, store, class, context or context-set
loc string? the file, for Open in editor

ops #

Page to panel. What one flush changed.

Field Type Meaning
ops TreeOp[] {op: 'add', node}, {op: 'update', node} or {op: 'remove', id}
commit number? the commit's sequence number, the same on every transport
t number? when the flush happened, in ms

An add or update carries the whole node: a node lists its own children, so a structural change is an update of the nodes it touches. A node is updated when its JSON differs from what was sent (opFor). The panel builds its history from these with commitFromOps, the same function the agent uses, so previous values never cross the wire.

history-request #

Panel to page. since: number: send the commits with a higher sequence number; 0 asks for all the page still holds.

history #

Page to panel. commits: Commit[] and truncated: boolean, which is true when the page dropped older commits. A commit is {seq, t, changes}; a change is {op, id, name, symbol?, from?, to?}. The page keeps at most 200 commits (HISTORY_COMMITS), from the moment the first panel opened.

inspect #

Panel to page. id, path ([symbol, ...steps]) and an optional limit: how many entries of a wide value to send. A step is a key, an array index, or for a Map or Set the entry's index; the rule is in agent/src/protocol/valuePath.ts.

hydrate #

Page to panel. The answer to inspect: the same id and path, and value, read from the app's live value and encoded one level deep, with a class instance at the top expanded into its fields. value is absent when the node or the path is gone.

edit #

Panel to page. id, symbol, path (the steps inside the value; empty replaces it) and value. The page writes into the app's live value, never the panel's copy: it copies the objects along the path and assigns the result as the whole value. It refuses, without an answer, when the value has no setter, when value holds any marker (the panel's copy has markers where it did not load the data), or when the path cannot be written.

highlight #

Panel to page. id: number | null: draw a box over that node's element, or clear it.

pick #

Panel to page. active: boolean: start or end element picking.

picked #

Page to panel. id: number | null: the node the user clicked, or null when they pressed Escape.

open-in-editor #

Panel to page. loc: a node's file or file:line. The page asks its own dev server to open it (/__open-in-editor?file=…), and answers with opened. A page whose Open in editor is 'vscode' or off ignores it: the panel only sends it when the hello says neither.

opened #

Page to panel. loc, and ok: whether the dev server took the request. launch-editor reports a failure to open an editor only in the dev server's terminal, so ok means the request reached it.

log #

Panel to page. id: log that node to the page's console.

storeGlobal #

Panel to page. id and symbol: store the app's own value as window.$r.

Control messages #

They travel the same channels but are not about the tree, so no side handles them together with its direction's kinds, and neither kind may be a Message kind. They carry source and kind like the others, and no from or to.

events #

events: RunespectEvent[]: a hop's event log, handed inward to the page where the agent merges it into window.__runespect.events. The relay posts it to the page window; the service worker sends it to the panel over the port; the panel, which cannot post to the page, evaluates window.__runespect.absorb(events) in it (absorbExpression). What an event holds is in Event log.

register #

tabId: number: a DevTools panel naming its tab to the extension's service worker, on its port. From then on the worker sends that panel's commands to that tab and the tab's messages to that panel.

Values #

A value goes out one level deep: a container inside a container becomes a dehydrated marker, which the panel opens with inspect. A container wider than 100 entries is cut, and a string longer than 10,000 characters. A symbol is sent as a string, Symbol(name). A class instance with $state fields (get/set pairs) or a getter-only current is reactive state and is sent with its fields; any other class instance is a stub. Everything else JSON cannot hold is an object with a __runespect tag:

Tag Fields For
undefined undefined
number value 'NaN', 'Infinity' or '-Infinity'
bigint value the digits
date iso the ISO string, null for an invalid date
regexp source, flags a regular expression
error-object name, message an Error held as a value
map entries [key, value] pairs; keys are encoded like values
set values the members
urlsearchparams entries [name, value] pairs, in order, a name possibly repeated
string head, length the first 10,000 characters of a longer string
instance name, fields a class instance: its own fields and its get/set pairs
function name a function, a method or a snippet
error message a value whose read threw: shown as the error
dehydrated type, preview, size, opaque? a container below the first level; opaque for binary data, which cannot be opened
partial value, size the first entries of a wide container, and how many it has

dehydrated.type is object, array, map, set, urlsearchparams, typedarray or arraybuffer. The encoding is dehydrate() in agent/src/protocol/dehydrate.ts, which must stay self-contained: the Tier 2 capture embeds its source.

Transports #

Transport is two methods, send(message) and onMessage(callback). The agent can hold several at once; a flush is still diffed once and every transport sends the same commit.

  • Window (createWindowTransport, in the agent): window.postMessage to the page's own window. It hears only messages whose event.source is that window, so an iframe cannot send edit.
  • HMR (createHotTransport): the runespect:msg event on Vite's HMR socket, between the page and the panel window at /__runespect/. The plugin's broker forwards only messages whose kind is in one of the two direction lists, and sends each to every client, its sender included.
  • Page channel (pageChannel): the same event over a BroadcastChannel, between the page and the panel window of an includeInRelease build. The browser delivers each message to every other document of the origin; there is no broker.
  • Extension, in four hops (the chain is drawn in Event log):
    • the content-script relay (extension/src/relay.ts) passes page-to-panel kinds from the window to chrome.runtime, and panel-to-page kinds back;
    • the service worker (routing.ts) keeps one panel port per tab, learned from register;
    • the panel's port (panel-port.ts), named runespect-panel (PANEL_PORT), reconnects when Chrome stops the idle worker, sends register again every 20 seconds, and sends request after a reconnect, because the tree it holds may be stale;
    • the panel itself (panel-hop.ts), which hands control messages to its event log.
  • Direct: no messages at all. The <Inspect> overlay runs in the page and reads the agent itself.

Other senders #

Two senders speak the page's side without the agent. Neither stamps from, and neither handles edit.

  • Names only (extension/src/content-tier1.ts): on a dev Svelte page without the plugin, the extension walks the DOM's __svelte_meta and posts a tree with names and locations but no values. It posts when the tree changed and on any request, sends no hello, and stays quiet once the page's window has __runespect_tier0 (TIER0_FLAG), which the agent and the capture set. A panel that has a tree and no tier two seconds after it opened shows Names only.
  • Tier 2 (packages/proxy, experimental): the capture's bridge posts a hello with tier: 2, the shared PROTOCOL_VERSION and a problem when the capture found nothing, and a read-only tree encoded with the same dehydrate(). See What the panel can show.