A Chrome DevTools extension for Puzzle apps: the live view tree, store records, the subscription graph, and routing — read out of a running dev build.
Puzzle ships a dev-only runtime bridge; this extension ships the page hook that bridge registers into. There are no production bytes on either side: a production build compiles the bridge away entirely, and a page with no bridge simply reports "No Puzzle app detected".
Requires Puzzle 0.3.1+ running a development build (puzzle dev). The bridge
is compiled out of production bundles, so a production page correctly reports no app.
| Panel | What it shows |
|---|---|
| Connection | Handshake state, framework/protocol versions, the last route commit, and a live ring of recent protocol messages. |
| Views | Elements-style master/detail over the live component tree. Expand/collapse per node, hover to highlight the view on the page, per-row log:view (binds $p), and a re-render pulse on rows a flush notified. Selecting a row inspects it: params, props, the view's store subscriptions, and the two state layers side by side — the data() model layer and the setData() local layer, which is the split the panel exists for. |
| Store | Record types with counts, a compact table of the active type (pk first, _synced as a badge), and a detail card that edits primitive fields through edit:record — applied by the runtime with the app's real record.update(), so §20 validation failures come back and render inline. An open card also keeps a per-flush change history (field: old → new). |
| Subscriptions | The store's reverse index as a panel: subscription keys grouped into collections (todo) and records (todo t2), and for the selected key, every view that re-renders when it changes — the blast radius of a write. Click a subscriber to land on it in Views. This is the panel other frameworks structurally cannot build: it is a lookup, not an inference. |
| Router | The live route card — pathname, route pattern, params, the frozen query snapshot — over the matched chain root→leaf, and a navigation history feed rebuilt from the event ring. |
| Performance | Record a session and see what it cost. Wasted renders lead — passes where the framework re-ran a view, re-diffed its tree and changed no DOM at all, which is the one number here that is unambiguously a bug. Under the totals: a sortable per-view table (renders, wasted, DOM mutations, render/patch/data ms) with a re-render heatmap scaled to the busiest view, hatching on views that are mostly waste, and a loud section for recursive-loop / runaway-rerender detections. Click a row to land on that view in Views. |
No panel polls — with one deliberate exception. The bridge writes monotonic counters into
the panel's own store (connection.viewSeq / flushSeq / perfSeq, pview.pulseAt); a
subscribed data() sees them move and schedules its own debounced request. A page with no
Puzzle app never gets a request at all.
The exception is the Performance panel, which polls snapshot:profile once a second
while recording and not otherwise. There is deliberately no per-render event: the page
hook buffers 500 messages before the panel attaches and the panel's ring holds 200, so a
render firehose would overrun both and evict the events every other panel depends on.
Counters are kept in the runtime and pulled. The only thing pushed is perf-warning,
which is rare and urgent.
INSPECTED PAGE EXTENSION DEVTOOLS
┌───────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ MAIN world │ │ ISOLATED world │ │ devtools.html │
│ │ │ │ │ └ devtools.js │
│ Puzzle runtime bridge │ │ content-script.js │ │ panels.create() │
│ client-runtime/ │ │ │ │ │
│ devtools.js (D100) │ │ window.postMessage │ │ panel.html │
│ │ │ │ ▲ │ │ │ ├ panel-glue.js │
│ │ emit / onRequest │ │ │ ▼ │ │ │ port + theme │
│ ▼ │ │ chrome.runtime port │ │ └ panel/app.js │
│ __PUZZLE_DEVTOOLS_HOOK__ │◄─┼──► 'puzzle-devtools- │ │ the Puzzle │
│ page-hook.js │ │ page' │ │ panel app │
└───────────────────────────┘ └────────────┬─────────────┘ └───────────┬──────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ background.js (MV3 service worker) │
│ pairs ports by tab id, routes both ways │
└─────────────────────────────────────────────┘
Each hop, and why it exists:
| File | World / context | Job |
|---|---|---|
page-hook.js |
page, MAIN world | Installs window.__PUZZLE_DEVTOOLS_HOOK__ at document_start. Buffers up to 500 events until the panel attaches, then streams. Relays requests to the bridge's handler and posts the answer back. |
content-script.js |
page, ISOLATED | Dumb relay: window.postMessage ↔ chrome.runtime port. Reconnects when the service worker is recycled. |
background.js |
service worker | Pairs a content-script port (sender.tab.id) with a panel port (puzzle-devtools-panel:<tabId>) and routes between them. Stateless — the maps are rebuilt from live ports. |
devtools.js |
devtools page | chrome.devtools.panels.create('Puzzle', …, 'panel.html'). |
panel-glue.js |
panel page | The only file that touches chrome.*: connects the port, correlates requests by id (5s timeout), applies data-theme from chrome.devtools.panels.themeName. |
panel/ |
panel page | The panel UI — itself a Puzzle app. Protocol events land in its store as records; views are ordinary reactive Puzzle views. |
protocol/ |
shared | Message-type names and PROTOCOL_VERSION, the single source in this repo. |
The MAIN-world hook is unavoidable: a content script in the isolated world cannot see
page globals, and the runtime bridge cannot see extension APIs. window.postMessage is
the only channel they share.
This repo owns the shape of window.__PUZZLE_DEVTOOLS_HOOK__; the framework bridge
consumes it.
window.__PUZZLE_DEVTOOLS_HOOK__ = {
hookVersion: 1, // shape of THIS object
protocolVersion: 1, // wire protocol it expects
emit(message), // runtime → extension; buffered until the panel attaches
onRequest(handler), // handler(message) => payload; returns an unsubscribe fn
};handler is called synchronously and is expected to be total — the framework
returns { error } rather than throwing. A throw is handled anyway (it becomes an
{ id, error } answer), and a returned promise is awaited.
Every message, both directions, is:
{ puzzle: 1, v: 1, type, payload }Protocol v1. The authority is the framework spec —
constellation/doc/DOC-SPEC.md §55, "The DevTools bridge and wire protocol"
— and protocol/constants.js transcribes it. tests/protocol.test.js asserts the name
lists literally, so a rename upstream breaks this repo's suite instead of silently
breaking the panel.
Events (runtime → extension): hello · app-mounted · app-unmounted ·
view-mounted · view-destroyed · flush · route-commit · perf-warning
Requests (extension → runtime): snapshot:views · inspect:view ·
snapshot:records · snapshot:subscriptions · snapshot:route · edit:record ·
highlight:view · log:view · log:record · perf:start · perf:stop ·
snapshot:profile
Versions are exchanged in hello. A protocol version outside
SUPPORTED_PROTOCOL_VERSIONS puts the panel in an explicit mismatch state rather than
misrendering.
The message set grows additively, without a version bump. Both ends already tolerate
names they do not know — an unrecognized event falls through to the event ring, an
unrecognized request comes back as a per-call { error } — so a newer panel still renders
everything an older runtime sends. That is why the profiler messages above are v1: bumping
would have put every already-published app into the hard mismatch state and blanked all
six panels, to buy nothing. A runtime with no profiler simply reports the failure inside
the Performance panel and leaves the rest working.
npm installThat is the whole setup. @magic-spells/puzzle comes from npm, and its puzzle shim
lands at node_modules/.bin/puzzle — which is what compiles the panel. Nothing outside
this repo is required.
To build the panel against an unpublished framework checkout instead (developing the
bridge and the panel together), point PUZZLE_BIN at a compiler you built:
cd /path/to/puzzle/compiler && go build -o ../puzzle ./cmd/puzzle
PUZZLE_BIN=/path/to/puzzle/puzzle npm run buildnpm run build # panel + dist-extension/
npm run build:zip # ... and puzzle-devtools-<version>.zip
node scripts/build.mjs --dev # unminified panel with a sourcemapscripts/build.mjs:
- runs the puzzle compiler's
buildinpanel/, producingpanel/dist/{app.js,styles.css}; - assembles
dist-extension/= everything inextension/plus the panel bundle underdist-extension/panel/, matching thesrc/hrefpaths inpanel.html; - verifies every file the manifest and
panel.htmlreference actually exists — Chrome reports those as vague load failures, so they are caught here; - with
--zip, packs the directory.
chrome://extensions- Enable Developer mode
- Load unpacked → select
dist-extension/ - Open DevTools on a page running a Puzzle dev build → the Puzzle panel
Reloading the extension requires reloading inspected pages too: the page hook is
injected at document_start and a live page never gets a second chance.
test/fixture-page/index.html is the permanent protocol test double: an inline script
that plays the runtime bridge — same envelope, same event order, same response shapes as
client-runtime/devtools.js — with canned data. It exercises the whole pipe without a
framework app, which is what keeps the suite honest about protocol shapes.
npm run serve:fixture # http://localhost:5177/Serve it over http, not file://: content scripts are not injected into file:// pages
unless the user grants file access.
What it does on load:
- waits for
__PUZZLE_DEVTOOLS_HOOK__, reports what it found in the page; - emits
hello→app-mounted, registers its request handler, then replays oneview-mountedper live view; - emits a
flushevery 2s with rotating keys, and oneroute-commitat 5s; - answers all twelve request types —
snapshot:viewsreturns a six-node, three-level tree rebuilt from a flat parent-linked list (so views added at runtime appear in it),snapshot:recordstwo types of five-plus fields each including a boolean and a number, and unknown types come back as{ error }; - validates
edit:recordbefore applying it, the way §20 does: an empty required string answers{ error: 'text cannot be empty' }, a primary-key change and an out-of-range number are refused, and a patch that fails anywhere is applied nowhere; - profiles on demand:
perf:startbegins accumulating a canned per-view cost every 500ms (deterministic, noMath.random, so sort order and heat buckets are reproducible),snapshot:profilereports it as a pure read, andperf:stopfreezes the counters without discarding them. FixtureRow #3 is the pathological view — 24 renders per tick, 21 of them wasted — so the panel's default sort has an unambiguous top row. Flushes that land during a recording become the report's store-side timeline; - offers buttons to emit each event on demand, two that fire the loop detector
(
runaway-rerenderandrecursive-loop, counting up on repeat rather than duplicating), and a box thathighlight:viewoutlines.
Expected panel behavior: the Connection view flips from "No Puzzle app detected" to
"Puzzle app connected", the message list fills, and Probe snapshot:views answers
"1 root(s), 6 view(s)". The Views panel shows the tree with FixtureRow #3's
completed differing between the two state layers; the Store panel lists todo and
user, and typing an empty text into a todo and pressing Apply renders the validation
message inline.
npx vitest run| Suite | Covers |
|---|---|
protocol.test.js |
Type names match SPEC §55 literally; the constants duplicated into the classic scripts agree with protocol/constants.js; manifest shape. |
page-hook.test.js |
Buffering, ordered replay, the 500-event cap, request/response id correlation, error paths, same-window filtering. |
panel-glue.test.js |
Port naming, envelope construction, id correlation under out-of-order answers, the request timeout, { error }-result unwrapping, status events. |
background.test.js |
Port pairing by tab id, both routing directions, the replayed listening control, stale-port replacement, rejection of malformed ports. |
fixture-page.test.js |
The real page hook driving the real fixture script: the event stream, every request's response shape, the edit:record validation paths, and the profiler (report shape, accumulation, stop-keeps-counters, pushed warnings and their dedupe-by-count). |
panel-app.test.js |
The compiled panel bundle booting against a stub bridge that reproduces panel-glue's { error }-to-rejection contract: the shell (connection states, view tracking, the event ring), Views (tree render, indentation, expand/collapse, selection → both state layers, subscriptions group, flush pulse, debounced re-snapshot, highlight/log requests, arrow keys), Store (type list, table shape, detail card, edit success, validation error, change history), Subscriptions (rail grouping, subscriber lists, cross-tab hand-off), Router (card, chain, history feed), and Performance (record/stop, the polling window opening and closing with it, the wasted-renders headline, column sorting, heat scaling, pushed warnings, the cross-link, and session reset on navigation). Skipped when panel/dist/app.js has not been built. |
values.test.js |
The pure projection helpers with no DOM: view-kind derivation, module-label redundancy rules, subscription-key parsing (the type id space separator — a colon regression fails here), the record differ, history capping, and the profiler projections (formatting, waste ratios, relative heat buckets, the tiebreak that stops a once-a-second table from reshuffling, and the warning merge). |
There is no Chrome automation; loading the unpacked extension is a manual smoke test.
extension/ the extension package (copied verbatim into dist-extension/)
panel/ the panel UI — a Puzzle app (app/, puzzle.config.js)
protocol/ message names + PROTOCOL_VERSION
scripts/ build.mjs, serve-fixture.mjs, make-icons.mjs
test/fixture-page/ the synthetic bridge
tests/ vitest suites
MIT