Skip to content

Commit b56b0af

Browse files
authored
Merge branch 'main' into worktree-kind-orbiting-dolphin
2 parents cd9e8ef + 9bf08e2 commit b56b0af

23 files changed

+2377
-187
lines changed

docs/architecture.md

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
---
2+
title: Architecture Overview
3+
id: architecture
4+
---
5+
6+
TanStack Devtools is a modular system of packages organized into three layers: **Framework Adapters**, **Core Shell**, and **Event Transport**. This architecture lets you use pre-built devtools panels or build your own custom ones, regardless of which frontend framework you use.
7+
8+
```mermaid
9+
block-beta
10+
columns 1
11+
block:framework["Framework Layer"]
12+
columns 4
13+
React Vue Solid Preact
14+
end
15+
block:core["Core Layer"]
16+
columns 3
17+
Shell["Devtools Shell"] UI["UI Components"] Client["Event Client"]
18+
end
19+
block:transport["Transport Layer"]
20+
columns 3
21+
ClientBus["Client Event Bus"] ServerBus["Server Event Bus"] Vite["Vite Plugin"]
22+
end
23+
24+
framework --> core
25+
core --> transport
26+
```
27+
28+
## Package Dependency Graph
29+
30+
```mermaid
31+
graph TD
32+
subgraph Framework["Framework Adapters"]
33+
react["@tanstack/react-devtools"]
34+
vue["@tanstack/vue-devtools"]
35+
solid["@tanstack/solid-devtools"]
36+
preact["@tanstack/preact-devtools"]
37+
end
38+
39+
subgraph Core["Core Layer"]
40+
shell["@tanstack/devtools<br/><i>Core shell (Solid.js)</i>"]
41+
client["@tanstack/devtools-client<br/><i>Core devtools events</i>"]
42+
eventClient["@tanstack/devtools-event-client<br/><i>Generic EventClient</i>"]
43+
ui["@tanstack/devtools-ui<br/><i>Shared UI components</i>"]
44+
clientBus["@tanstack/devtools-event-bus/client<br/><i>ClientEventBus</i>"]
45+
end
46+
47+
subgraph Build["Build Layer"]
48+
vite["@tanstack/devtools-vite"]
49+
serverBus["@tanstack/devtools-event-bus/server<br/><i>ServerEventBus</i>"]
50+
end
51+
52+
subgraph Utilities
53+
utils["@tanstack/devtools-utils"]
54+
end
55+
56+
react --> shell
57+
vue --> shell
58+
solid --> shell
59+
preact --> shell
60+
61+
shell --> client
62+
shell --> ui
63+
shell --> clientBus
64+
client --> eventClient
65+
66+
vite --> client
67+
vite --> serverBus
68+
69+
utils --> ui
70+
```
71+
72+
Each framework adapter depends only on `@tanstack/devtools`. The core shell pulls in everything it needs, so end users install just two packages: their framework adapter and the Vite plugin.
73+
74+
## Transport Layer
75+
76+
The transport layer handles event delivery between plugins, the devtools UI, and (optionally) a dev server. It is composed of three pieces.
77+
78+
### ServerEventBus (`@tanstack/devtools-event-bus/server`)
79+
80+
Runs inside the Vite dev server process (Node.js). It creates an HTTP server (or piggybacks on Vite's existing server when HTTPS is enabled) that accepts both **WebSocket** and **SSE** connections. When a message arrives from any client, the server broadcasts it to every other connected client and dispatches it on a server-side `EventTarget` so server-side listeners (like the Vite plugin's package-manager helpers) can react to it.
81+
82+
Key details:
83+
- Default port is `4206`, auto-increments if the port is in use.
84+
- Handles `/__devtools/ws` for WebSocket upgrades, `/__devtools/sse` for SSE streams, and `/__devtools/send` for SSE POST fallback.
85+
- Sets `globalThis.__TANSTACK_EVENT_TARGET__` so that `EventClient` instances running on the server can dispatch events onto the same target.
86+
87+
### ClientEventBus (`@tanstack/devtools-event-bus/client`)
88+
89+
Runs in the browser. Started automatically when the core shell mounts via `TanStackDevtoolsCore.mount()`. Its responsibilities:
90+
91+
1. **Local dispatch** -- Listens for `tanstack-dispatch-event` CustomEvents on `window`, re-dispatches them as both a type-specific CustomEvent (e.g. `my-plugin:state-update`) and a global `tanstack-devtools-global` event so listeners can subscribe to individual event types or to all events.
92+
2. **Server forwarding** -- If connected to a server bus, forwards every dispatched event over WebSocket (preferred) or SSE POST fallback.
93+
3. **Cross-tab sync** -- Uses `BroadcastChannel('tanstack-devtools')` to replicate events across browser tabs without round-tripping through the server.
94+
4. **Connection handshake** -- Responds to `tanstack-connect` events with `tanstack-connect-success`, allowing `EventClient` instances to discover the bus.
95+
96+
### EventClient (`@tanstack/devtools-event-client`)
97+
98+
The high-level, typed API that plugins use to send and receive events. Each `EventClient` is created with a `pluginId` and a type map that defines the events it can emit and listen to.
99+
100+
```ts
101+
import { EventClient } from '@tanstack/devtools-event-client'
102+
103+
type MyEvents = {
104+
'state-update': { count: number }
105+
'reset': void
106+
}
107+
108+
const client = new EventClient<MyEvents>({ pluginId: 'my-plugin' })
109+
```
110+
111+
When you call `client.emit('state-update', { count: 42 })`, the EventClient:
112+
113+
1. Dispatches a CustomEvent on its internal `EventTarget` (for same-page listeners using the `withEventTarget` option).
114+
2. Dispatches a `tanstack-dispatch-event` CustomEvent on the global target (typically `window`), with a payload of `{ type: 'my-plugin:state-update', payload: { count: 42 }, pluginId: 'my-plugin' }`.
115+
3. The `ClientEventBus` picks up that `tanstack-dispatch-event`, re-dispatches it as a `my-plugin:state-update` CustomEvent on `window`, and forwards it to the server bus via WebSocket.
116+
117+
When you call `client.on('state-update', callback)`, the EventClient registers a listener on the global target for `my-plugin:state-update` events, so it receives events regardless of whether they came from a local emit or from the server bus.
118+
119+
> [!NOTE]
120+
> The server bus is optional. Without the Vite plugin, `EventClient` still works for same-page communication via CustomEvent dispatch on `window`. Events simply won't cross tab or process boundaries.
121+
122+
### Event Flow Summary
123+
124+
```mermaid
125+
flowchart LR
126+
emit["EventClient.emit()"] --> dispatch["CustomEvent<br/><b>tanstack-dispatch-event</b><br/>on window"]
127+
dispatch --> bus["ClientEventBus"]
128+
bus --> local["Re-dispatch as<br/><b>my-plugin:state-update</b><br/>on window"]
129+
local --> onLocal["EventClient.on()<br/>callbacks fire"]
130+
bus --> bc["BroadcastChannel"]
131+
bc --> otherTabs["Other tabs<br/>receive event"]
132+
bus --> ws["WebSocket"]
133+
ws --> server["ServerEventBus"]
134+
server --> broadcast["Broadcast to all<br/>connected clients"]
135+
broadcast --> remote["EventClient.on()<br/>callbacks fire<br/>in other clients"]
136+
```
137+
138+
## Core Layer
139+
140+
### @tanstack/devtools -- The Shell
141+
142+
The devtools shell is a Solid.js application that renders the entire devtools UI. It exposes the `TanStackDevtoolsCore` class with three methods:
143+
144+
- **`mount(el)`** -- Renders the Solid.js devtools application into the given DOM element. Starts a `ClientEventBus` and lazy-loads the UI. Wraps everything in a `DevtoolsProvider` (reactive store for plugins, settings, state) and a `PiPProvider` (Picture-in-Picture support).
145+
- **`unmount()`** -- Tears down the Solid.js app and stops the event bus.
146+
- **`setConfig(config)`** -- Updates configuration and plugins at runtime. Plugins are reactive: adding or removing them updates the tab bar immediately.
147+
148+
The shell renders:
149+
- A **trigger button** (the floating devtools toggle, customizable or replaceable)
150+
- A **resizable panel** (docked to the bottom of the viewport, resizable via drag)
151+
- **Tab navigation** for switching between plugins, settings, SEO inspector, and the plugin marketplace
152+
- A **settings panel** for theme, hotkeys, position, and other preferences
153+
- **Plugin containers** -- DOM elements where each plugin's UI is mounted
154+
155+
Settings and UI state (panel size, position, active tab, theme) are persisted in `localStorage` so they survive page reloads.
156+
157+
### @tanstack/devtools-ui -- Component Library
158+
159+
A shared Solid.js component library used by the core shell and available for use in Solid.js plugins. Provides buttons, inputs, checkboxes, a JSON tree viewer, section layouts, and other UI primitives. The `@tanstack/devtools-utils` package also depends on it to provide framework-specific plugin helpers.
160+
161+
### @tanstack/devtools-client -- Core Event Client
162+
163+
A specialized `EventClient` pre-configured with `pluginId: 'tanstack-devtools-core'` and a fixed event map for devtools-internal operations:
164+
165+
- `mounted` -- Fired when the devtools UI has mounted, triggers the server to send current package.json and outdated dependency data.
166+
- `package-json-read` / `outdated-deps-read` -- Carries project metadata from the Vite server to the devtools UI.
167+
- `install-devtools` / `devtools-installed` -- Request/response cycle for installing a plugin package from the marketplace.
168+
- `add-plugin-to-devtools` / `plugin-added` -- Request/response cycle for injecting a plugin into the user's source code.
169+
- `trigger-toggled` -- Synchronizes the open/closed state of the devtools panel.
170+
171+
This client is a singleton (`devtoolsEventClient`) used by both the core shell and the Vite plugin to coordinate.
172+
173+
## Framework Layer
174+
175+
Each framework adapter is a thin wrapper that bridges its framework's component model to the core Solid.js shell. The pattern is the same across all adapters:
176+
177+
1. **Creates a `TanStackDevtoolsCore` instance** with the user's plugins and config.
178+
2. **Mounts it to a DOM element** using the framework's lifecycle hooks (`useEffect` in React, `onMounted` in Vue, `onMount` in Solid).
179+
3. **Converts framework-specific plugin definitions** into the core's DOM-based `render(el, theme)` interface. Each adapter defines its own plugin type (e.g. `TanStackDevtoolsReactPlugin`) that accepts framework-native components, then wraps them in a `render` callback that the core calls with a target DOM element and the current theme.
180+
4. **Uses the framework's portal/teleport mechanism** to render plugin components into the core's DOM containers:
181+
- **React** -- `createPortal()` from `react-dom`
182+
- **Vue** -- `<Teleport :to="'#' + plugin.id" />`
183+
- **Solid** -- `<Portal mount={el} />`
184+
- **Preact** -- Same portal pattern as React
185+
186+
The key insight: the core shell is always Solid.js, but your plugins run in **your** framework. A React plugin is a real React component rendered by React's `createPortal` into a DOM element that the Solid.js shell created. A Vue plugin is a real Vue component rendered by Vue's `<Teleport>`. The adapters bridge this gap so you never need to think about Solid.js unless you want to.
187+
188+
### What an adapter does NOT do
189+
190+
Adapters do not re-implement the devtools UI, manage settings, handle events, or communicate with the server. All of that lives in the core shell. Adapters are intentionally minimal -- typically a single file under 300 lines.
191+
192+
## Build Layer
193+
194+
`@tanstack/devtools-vite` is a collection of Vite plugins that enhance the development experience and clean up production builds. It returns an array of Vite plugins, each handling a specific concern:
195+
196+
### Source injection (`@tanstack/devtools:inject-source`)
197+
Uses Babel to parse JSX/TSX files and injects `data-tsd-source` attributes on every JSX element. These attributes encode the file path, line number, and column number of each element in source code, which the source inspector feature uses to implement click-to-open-in-editor.
198+
199+
### Server event bus (`@tanstack/devtools:custom-server`)
200+
Starts a `ServerEventBus` on the Vite dev server. Also sets up middleware for the go-to-source editor integration and bidirectional console piping (client logs appear in the terminal, server logs appear in the browser).
201+
202+
### Production stripping (`@tanstack/devtools:remove-devtools-on-build`)
203+
On production builds, transforms any file that imports from `@tanstack/*-devtools` to remove the devtools imports and JSX usage entirely. This means devtools add zero bytes to your production bundle.
204+
205+
### Console piping (`@tanstack/devtools:console-pipe-transform`)
206+
Injects a small runtime into your application's entry file that intercepts `console.log/warn/error/info/debug` calls and forwards them to the Vite dev server via HTTP POST. The server then broadcasts them to connected SSE clients, enabling server-to-browser log forwarding.
207+
208+
### Enhanced logging (`@tanstack/devtools:better-console-logs`)
209+
Transforms `console.*` calls to prepend source location information (file, line, column), making it possible to click a console log and jump directly to the source.
210+
211+
### Plugin marketplace support (`@tanstack/devtools:event-client-setup`)
212+
Listens for `install-devtools` events from the devtools UI, runs the package manager to install the requested package, and then uses AST manipulation to inject the plugin import and configuration into the user's source code.
213+
214+
### Connection injection (`@tanstack/devtools:connection-injection`)
215+
Replaces compile-time placeholders (`__TANSTACK_DEVTOOLS_PORT__`, `__TANSTACK_DEVTOOLS_HOST__`, `__TANSTACK_DEVTOOLS_PROTOCOL__`) in the event bus client code with the actual values from the running dev server, so the client automatically connects to the correct server.
216+
217+
## Data Flow
218+
219+
To tie everything together, here is what happens when a plugin emits an event end-to-end:
220+
221+
1. **Your library code** calls `eventClient.emit('state-update', data)`.
222+
223+
2. **EventClient** constructs a payload `{ type: 'my-plugin:state-update', payload: data, pluginId: 'my-plugin' }` and dispatches it as a `tanstack-dispatch-event` CustomEvent on `window`.
224+
225+
3. **ClientEventBus** receives the `tanstack-dispatch-event`. It does three things:
226+
- Dispatches a CustomEvent named `my-plugin:state-update` on `window` so that any `eventClient.on('state-update', callback)` listeners on this page fire immediately.
227+
- Dispatches a `tanstack-devtools-global` CustomEvent on `window` so that `onAll()` and `onAllPluginEvents()` listeners fire.
228+
- Posts the event to the `BroadcastChannel` so other tabs receive it.
229+
230+
4. **If connected to the server bus**, ClientEventBus also sends the event over WebSocket to `ServerEventBus`.
231+
232+
5. **ServerEventBus** receives the WebSocket message and broadcasts it to all other connected clients (WebSocket and SSE). It also dispatches the event on its server-side `EventTarget` so server-side listeners (e.g., the Vite plugin) can react.
233+
234+
6. **In other browser tabs/windows**, the event arrives via WebSocket from the server (or via BroadcastChannel from step 3). The local `ClientEventBus` dispatches it as a `my-plugin:state-update` CustomEvent, and any `eventClient.on('state-update', callback)` listeners fire with the data.
235+
236+
Without the Vite plugin and server bus, steps 4-6 are skipped, but steps 1-3 still work. This means plugins can communicate within a single page without any server infrastructure -- the server bus just adds cross-tab and cross-process capabilities.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
---
2+
title: Bidirectional Communication
3+
id: bidirectional-communication
4+
---
5+
6+
Most devtools plugins observe state in one direction: app to devtools. But `EventClient` supports two-way communication. Your devtools panel can also send commands back to the app. This enables features like state editing, action replay, and time-travel debugging.
7+
8+
```mermaid
9+
graph LR
10+
subgraph app["Application"]
11+
state["State / Library"]
12+
end
13+
subgraph ec["EventClient"]
14+
direction TB
15+
bus["Event Bus"]
16+
end
17+
subgraph dt["Devtools Panel"]
18+
ui["Panel UI"]
19+
end
20+
21+
state -- "emit('state-update')" --> bus
22+
bus -- "on('state-update')" --> ui
23+
ui -- "emit('reset')" --> bus
24+
bus -- "on('reset')" --> state
25+
```
26+
27+
## Pattern: App to Devtools (Observation)
28+
29+
The standard one-way pattern. Your app emits events, the devtools panel listens.
30+
31+
```ts
32+
// In your app/library
33+
eventClient.emit('state-update', { count: 42 })
34+
35+
// In your devtools panel
36+
eventClient.on('state-update', (e) => {
37+
setState(e.payload)
38+
})
39+
```
40+
41+
## Pattern: Devtools to App (Commands)
42+
43+
The panel emits command events, the app listens and reacts.
44+
45+
```ts
46+
// In your devtools panel (e.g., a "Reset" button click handler)
47+
eventClient.emit('reset', undefined)
48+
49+
// In your app/library
50+
eventClient.on('reset', () => {
51+
store.reset()
52+
})
53+
```
54+
55+
You need to define both directions in your event map:
56+
57+
```ts
58+
type MyEvents = {
59+
// App → Devtools
60+
'state-update': { count: number }
61+
// Devtools → App
62+
'reset': void
63+
'set-state': { count: number }
64+
}
65+
```
66+
67+
## Pattern: Time Travel
68+
69+
The most powerful bidirectional pattern. Combine observation with command-based state restoration.
70+
71+
```mermaid
72+
sequenceDiagram
73+
participant App as Application
74+
participant EC as EventClient
75+
participant Panel as Time Travel Panel
76+
77+
App->>EC: emit('snapshot', { state, timestamp })
78+
EC->>Panel: on('snapshot') → collect snapshots
79+
Note over Panel: User drags slider to past state
80+
Panel->>EC: emit('revert', { state })
81+
EC->>App: on('revert') → restore state
82+
App->>EC: emit('snapshot', { state, timestamp })
83+
EC->>Panel: on('snapshot') → update timeline
84+
```
85+
86+
### Event Map
87+
88+
```ts
89+
type TimeTravelEvents = {
90+
'snapshot': { state: unknown; timestamp: number; label: string }
91+
'revert': { state: unknown }
92+
}
93+
```
94+
95+
### App Side
96+
97+
Emit snapshots on every state change:
98+
99+
```ts
100+
function applyAction(action) {
101+
state = reducer(state, action)
102+
103+
timeTravelClient.emit('snapshot', {
104+
state: structuredClone(state),
105+
timestamp: Date.now(),
106+
label: action.type,
107+
})
108+
}
109+
110+
// Listen for revert commands from devtools
111+
timeTravelClient.on('revert', (e) => {
112+
state = e.payload.state
113+
rerender()
114+
})
115+
```
116+
117+
### Panel Side
118+
119+
Collect snapshots and provide a slider:
120+
121+
```tsx
122+
function TimeTravelPanel() {
123+
const [snapshots, setSnapshots] = useState([])
124+
const [index, setIndex] = useState(0)
125+
126+
useEffect(() => {
127+
return timeTravelClient.on('snapshot', (e) => {
128+
setSnapshots((prev) => [...prev, e.payload])
129+
setIndex((prev) => prev + 1)
130+
})
131+
}, [])
132+
133+
const handleSliderChange = (newIndex) => {
134+
setIndex(newIndex)
135+
timeTravelClient.emit('revert', { state: snapshots[newIndex].state })
136+
}
137+
138+
return (
139+
<div>
140+
<input
141+
type="range"
142+
min={0}
143+
max={snapshots.length - 1}
144+
value={index}
145+
onChange={(e) => handleSliderChange(Number(e.target.value))}
146+
/>
147+
<p>
148+
State at: {snapshots[index]?.label} (
149+
{new Date(snapshots[index]?.timestamp).toLocaleTimeString()})
150+
</p>
151+
<pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre>
152+
</div>
153+
)
154+
}
155+
```
156+
157+
## Best Practices
158+
159+
- **Keep payloads serializable.** No functions, DOM nodes, or circular references.
160+
- **Use `structuredClone()` for snapshots** to avoid reference mutations.
161+
- **Debounce frequent emissions** if needed (e.g., rapid state changes).
162+
- **Use distinct event suffixes** for observation vs commands (e.g., `state-update` for observation, `set-state` for commands).

0 commit comments

Comments
 (0)