Architecture
How runespect gets from a Svelte component to a row in the panel.
Principle: report through the public API #
Svelte 5's runtime keeps its state in private, single-letter fields that change between releases
and exist only in dev builds. runespect does not read them. Instead, the Vite plugin adds code to
each component at build time, written in Svelte's public API, and that code reports to an agent in
the page. It breaks only if the public contract breaks: $effect, $state.,
getContext/setContext, onDestroy and plain assignment.
| Devtools need | How runespect gets it |
|---|---|
| Component tree | each component reads its parent's node from context and sets its own |
| Live values | a function per value that reads it, which the agent reads in an $effect |
| Names and locations | the file name and path, known at build time |
| Which value is what | $state, $derived, $props(), stores, context: read from the source |
| Editing | a setter per writable value, (v) => { count = v; }: a public assignment |
The only code that reads Svelte's internals is the experimental capture in packages/ and
packages/, which is not part of the released packages (see
What the panel can show).
Overview #
1. Instrumentation (packages/vite-plugin ) #
The package exports two functions, and an app needs both.
runespectPreprocess(): components #
A Svelte preprocessor (src/). When it runs, and on which files, is in
Options.
It parses the component with svelte/ and decides the component's mode the way Svelte
does, because the code it adds must not change that mode. The added code only hands the agent a
function that reads each value, which is legal in every mode, and a component that is neither runes
nor legacy only joins the tree. What a component or a module reports, and which of its values the
panel may write, is decided in one place (src/), from the parsed source.
Stores such a component reads are reported through their own subscription. Which values each mode
reports is listed in Options. Every matched component joins the
tree, including one without a <script>: the preprocessor adds one. For a runes component the
added code looks like this (the imports go at the start of the instance script, the rest at the
end):
import * as __runespect from '@runespect/agent/runtime';
__runespect.component({
name: 'Counter',
loc: '/app/src/Counter.svelte',
symbols: {
count: {
kind: 'state',
read: () => count,
write: (v) => {
count = v;
},
},
doubled: { kind: 'derived', read: () => doubled },
},
});
The plugin only describes what the file has; the agent's runtime does the rest (below).
- The name is the file name, and for a SvelteKit route file the route, as in
+page (blog/.[slug]) locis the file path, used by open-in-editor. kindtells the panel how to group each value.writeexists only for values a plain assignment can change:letstate,$bindable()props and$stateclass fields.$derived, other props, stores and context are read-only.- The closures stay in the app's file because Svelte must compile them there. A store in a
component without runes or legacy syntax is passed as
store, and read through its own subscription. - The edit is made with
magic-stringand returns a sourcemap, so errors and breakpoints still point at the original lines.
runespect(): the dev server, and a build that asks for it #
A Vite plugin (src/) that vite build does not run unless includeInRelease is on. It:
- Adds the broadcast module
virtual:runespect-broadcastto the page. It calls the runtime'sstart({ hot: import., which reports to the window (the extension) and over the hot channel (the panel window). It goes in through a script tag frommeta. hot }) transformIndexHtml, or, under SvelteKit (which never runs that hook), appended to SvelteKit's generated client entry. - Resolves the agent from the plugin itself (its runtime, its internals and its public entry
alike), and excludes
@runespect/andagent @runespect/from dependency pre-bundling, so the components, the broadcast module and the overlay share one agent.ui - Instruments
.andsvelte. ts .modules (svelte. js src/): top-levelmodule-state. ts $state,$derivedand rune-class instances are reported under a Modules root in the tree. A privateletstate is editable; an exported one is not, because Svelte refuses a module that reassigns state it exports. - Brokers the HMR channel and serves the panel window at
/(below).__runespect/ - Sets the
RUNESPECT_DEV_SERVERenvironment variable, which is howrunespectPreprocess()knows the dev server is running, and removes it when the last dev server in the process closes, so a build run later in the same process is not instrumented. Vitest also starts Vite in serve mode, so under Vitest it is not set.
With includeInRelease, vite build runs it too, and it differs in these ways:
RUNESPECT_RELEASE_ROOTtakes the place ofRUNESPECT_DEV_SERVERfor as long as an environment builds. It also holds the project root, because paths in a build are relative to it.- The broadcast module passes
pageChannel()as the channel, and Open in editor is off. - There is no broker and no middleware. The panel window goes in the build as files, with a
metatag that tells it to usepageChannel()in place of the dev server's channel. WithpanelInRelease: false, neither the files nor the channel are in the build.
2. The agent (packages/agent ) #
The injected code calls the runtime (src/, @runespect/) and nothing
else: component() while a component starts, module() when a . module runs, and
start() once per page. The runtime finds a component's parent through Svelte's context, leaves
the tree with onDestroy (not an $effect, so no component is forced into runes mode), and lets
an HMR-replaced module go. Whatever fails in it warns once and leaves the app running. Behind it is
one agent per page, which touches no Svelte internals. src/ puts it together
from parts that each own their state and import only the parts before them: tree (nodes, readers,
edits), history, wire (serialization and the flush watcher), observe (the event log),
page-actions, surface (window.) and transport.
- Tree.
enter(name, parent, meta)adds a node and returns its id;leave(id)removes it and its setters. A module that runs again after HMR replaces its node. On the server every call is a no-op, so SSR does not leak nodes. - Quiet until a panel opens. Reading a value evaluates it, so a
$derivedthe app never reads would run on every change. Until a panel asks, the agent reads nothing, watches nothing and sends only itshello. The firstrequest(the panel window, the extension), the overlay or a call towindow.starts it, and it keeps going for the page's lifetime.__runespect - Values.
reportFrom(id, symbol, read)keeps the function and, once a panel has opened, reads it in an$effectof the agent's own, so a$derivedthat throws is shown as its error instead of breaking the component. Values are copied with$state., except values that hold functions or class instances, which are kept as they are and stubbed when sent. Asnapshot URLSearchParamsis copied, and a reactive class such asMediaQueryis read throughcurrent, so a change to either reports again. - Editing.
bind(id, symbol, setter)stores the setter. An edit of a nested field copies only the objects on its path from the app's live value and assigns the new root. - Changes. Once a panel has opened, one
$effect.watches the tree. Each flush becomes a list of ops (add, update, remove) and a commit in the history, which therefore starts when the first panel opens: at most 200 commits and about 4 MB, after which the oldest are dropped and the history is marked truncated.root - Page actions. Highlight a component's element, pick an element, log a node to the console,
store a value as
window.: the app's own object, so a call on it changes the app.$r window.. The tree, values, history and__runespect set()for the console, a test or a coding agent, plus the event log of every hop (see Event log).- A second copy of the agent in the page logs a warning, because components would be split between the two.
@runespect/ itself exports only the types of window.. The injected code calls
@runespect/; the overlay and the tests use @runespect/. Both are
released together with the plugin.
3. The protocol #
The messages and value encoding shared by the agent and every panel
(packages/). The page announces itself with hello and sends its
tree once, then a delta per flush; the panel sends commands addressed to the page it follows. Values
go one level deep, and the panel asks for more.
Every message, field, sequence and encoding is in Protocol.
PROTOCOL_VERSIONis compared in the handshake; a mismatch shows as a warning in the panel.@runespect/exports the types;agent/ protocol @runespect/the wire format, which can change with the protocol version.agent/ protocol/ internal
4. Transports #
connect(transport) takes anything with send and onMessage. The agent can be connected to
several at once; a flush is still diffed and recorded once.
- Window (
createWindowTransport):window., for the extension. It hears only messages its own window sent, and drops its own echoes by a per-instance tag.postMessage - Vite HMR (
createHotTransport): therunespect:msgevent on Vite's HMR socket, for the panel window. Same echo tag. - Extension (
packages/): the content script relays between the page'sextension postMessageandchrome.(runtime src/). The service worker routes by tab to that tab's DevTools panel (relay. ts src/), whose port reconnects when Chrome stops the idle worker (routing. ts src/).panel-port. ts - Page channel (
pageChannel): aBroadcastChannelin the shape of the hot channel, between the page and the panel window of anincludeInReleasebuild, which has no dev server. - Direct: the
<Inspect>overlay calls the agent in the same page, without messages.
Who can talk to the agent #
A production build carries no runespect, unless includeInRelease puts it there. The window transport hears only messages its own
window sent, so an iframe on the page (an ad, an embed) cannot send edit. The HMR broker forwards
only messages of the kinds above; Vite's own token keeps other sites off the socket, while anyone who
can open the app (on the LAN with --host) can also open the panel, and the dev server already
serves them the source. edit calls the setter of one declared value with plain data: no code runs,
and a sender never sees the tree. The page URL in hello has secret-looking query values replaced.
5. The panel UI (packages/ui ) #
<Panel> (in @runespect/) renders the tree, the selected component's values, the
history and the status chip. It depends on nothing but a transport, which is why the same component
runs in the extension's DevTools tab and in the panel window.
<Inspect>, the one public export of @runespect/, is the in-page overlay. The app mounts it
itself. It renders inside its own shadow root, so the page's CSS cannot reach it, and reads the
agent directly.
The status chip comes from src/: Live when the page's hello says so, Names only
when a tree has arrived with no tier two seconds after the panel opened, and a warning for a
protocol mismatch or when no page answers. They are described in
What the panel can show.
The panel window (/__runespect/ ) #
<Panel> depends on nothing but a Transport — two methods, send and onMessage. That seam is
what lets the same component run in three places: the browser extension (chrome runtime port), the
playground's panel page (apps/, window.postMessage) and the standalone window the Vite plugin serves.
In a build the dev server is gone, and a BroadcastChannel joins the two tabs directly.
Details worth knowing:
- Why Vite's HMR channel and not a WebSocket server of our own: no new dependency, no second
port, and it is already
wsswhen the app runs over https — a plainws:/from an https page is blocked as mixed content./ - Why the panel is prebuilt (
panel/→vite. config. ts panel-dist/, served by middleware): the host app's Svelte pipeline must not compile the devtools — runespect's own preprocessor would instrument them. - Why
createHotContextand notimport.: a build replacesmeta. hot import.withmeta. hot undefined, which would silently tree-shake the whole panel away.createHotContextfrom/is the same primitive Vite injects when it rewrites@vite/ client import., and it is left external so the dev server resolves it at runtime.meta. hot - Echo filtering: the broker rebroadcasts to every client, the sender included, so each end tags
its sends and drops its own — the same guard
createWindowTransportuses. - Under a Vite
basethe window moves with the app:base: '/serves it atapp/ ' /(app/ __runespect/ src/).panel-route. ts - One channel, many clients: every page and every panel window on that dev server hears
everything — a second tab, another route, the playground's panel page, islands from separate bundles. The
panel therefore follows exactly one source, and says which one.
- Each page announces
{ id, url, title }in itshelloand stampsfromon everything it sends.idis new per page load, because a reload is a new agent with a new tree. - A page a panel has spoken to says
byewhen it goes, and the next page in its tab names it inreplaces(kept in the tab'ssessionStorage).ui/holds whether each page is there (alive, left and maybe reloading, or replaced and pinged);sources. ts ui/reacts only to what that did (arrived, replaced, gone, …) and holds the page followed: the explicit choice → the url it followed last → a tab that is shown → the first heard. It keeps to that page while it is there. Waits are deadlines in the state, and the view sets the timers.follow. ts - The status chip names the followed page, and becomes a picker when there is more than one.
- Filtering is panel-side, not transport-side, on purpose: a transport that bound to one peer would hide the other pages before the panel could offer them.
- Commands are addressed: the panel stamps
towith the page it follows, and a page ignores anything meant for another. Without it, one shared channel would deliver every command everywhere — an edit from the panel window would change the same component in every open tab. /says which page a panel window was opened for, and beats the remembered choice. With several pages live, first-heard-wins is a coin flip; this is how the e2e suite (and a developer with two tabs) says what it means. Until that page answers, the panel follows no other and shows nothing it hears: it would ask that page by name and wake it.__runespect/ ?source=/ some/ path - A page starts reading when a panel asks it by name. The panel's first
requestgoes to every page and only learns who is there, from theirhellos; then it asks the page it follows.
- Each page announces
Limits #
- Only declared values are visible. State created inside a function, or held by a library component, is not shown.
- Library components are not instrumented: files under
node_modulesare skipped. - Props are read-only unless declared
$bindable(). - History starts when the first panel opens. What happened before is not recorded.
- Cost: none to speak of until a panel opens; then one
$effectper reported value, in dev only. What it costs has measurements.