Skip to content

Commit e17a12a

Browse files
antfuclaude
andauthored
feat: support remote-hosted iframe docks with local RPC connection (#294)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dc88789 commit e17a12a

File tree

20 files changed

+1319
-112
lines changed

20 files changed

+1319
-112
lines changed

docs/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const DevToolsKitNav = [
1212
{ text: 'Introduction', link: '/kit/' },
1313
{ text: 'DevTools Plugin', link: '/kit/devtools-plugin' },
1414
{ text: 'Dock System', link: '/kit/dock-system' },
15+
{ text: 'Remote Client', link: '/kit/remote-client' },
1516
{ text: 'RPC', link: '/kit/rpc' },
1617
{ text: 'Shared State', link: '/kit/shared-state' },
1718
{ text: 'Commands', link: '/kit/commands' },
@@ -81,6 +82,7 @@ export default extendConfig(withMermaid(defineConfig({
8182
{ text: 'Introduction', link: '/kit/' },
8283
{ text: 'DevTools Plugin', link: '/kit/devtools-plugin' },
8384
{ text: 'Dock System', link: '/kit/dock-system' },
85+
{ text: 'Remote Client', link: '/kit/remote-client' },
8486
{ text: 'RPC', link: '/kit/rpc' },
8587
{ text: 'Shared State', link: '/kit/shared-state' },
8688
{ text: 'Commands', link: '/kit/commands' },

docs/kit/dock-system.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ icon: {
121121
> [!TIP]
122122
> See the [File Explorer example](/kit/examples#file-explorer) for a iframe dock plugin with RPC and static build support.
123123
124+
### Remote-hosted UIs
125+
126+
If you'd rather not bundle a dist with your plugin, an iframe dock can point at a **hosted website** that connects back to the local dev server over WebSocket. See [Remote Client](./remote-client) for the full guide.
127+
124128
## Action Buttons
125129

126130
Action buttons run client-side scripts when clicked. They're perfect for:

docs/kit/remote-client.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Remote Client
6+
7+
Remote client mode lets a dock point at a **hosted website** — e.g. `https://example.com/devtools` — instead of bundling a SPA dist with your plugin. The hosted page opens a WebSocket connection back to the local Vite dev server and talks to your plugin using the same RPC and shared-state APIs as an embedded client.
8+
9+
> [!TIP]
10+
> A live demo is hosted on this site at [Remote Connection Demo](./remote-demo). Register a dock pointing at that URL and open it to see the flow end-to-end.
11+
12+
Compared to the bundled approach described in [Dock System → Iframe Panels](./dock-system#iframe-panels), remote mode means:
13+
14+
- **No client dist shipped with your plugin.** Your npm package stays small; you ship only node-side code.
15+
- **Iterate on the hosted app independently.** Deploy updates to the UI without republishing the plugin.
16+
- **Use the production URL of an existing dashboard.** If your team already hosts something, surface it directly inside DevTools.
17+
18+
The tradeoff: users must be online to load the hosted page, and you trust the hosted origin to faithfully render local data.
19+
20+
## How it works
21+
22+
When you register an iframe dock with `remote: true`, DevTools:
23+
24+
1. Allocates a session-only, pre-approved auth token for that dock.
25+
2. Injects a connection descriptor — the WS URL, the token, and the user's dev-server origin — into the iframe's `src` attribute.
26+
3. Accepts the token on WebSocket handshake (after verifying the `Origin` header, if origin-lock is on).
27+
28+
On the hosted page, `connectRemoteDevTools()` parses the descriptor out of the URL and hands back a fully connected [`DevToolsRpcClient`](./rpc) — the same client you'd get from `getDevToolsRpcClient()` in an embedded page.
29+
30+
```mermaid
31+
sequenceDiagram
32+
participant User as User app (localhost:5173)
33+
participant Core as DevTools core
34+
participant Hosted as Hosted page (example.com)
35+
36+
User->>Core: configureServer
37+
Core->>Core: register dock (remote: true) → allocate token
38+
Core->>Core: createWsServer → wsEndpoint=ws://localhost:7812
39+
User->>Core: open dock
40+
Core->>Hosted: iframe src + connection descriptor
41+
Hosted->>Core: WS connect (?vite_devtools_auth_token=...)
42+
Core->>Core: verify token + Origin
43+
Core-->>Hosted: trusted session
44+
Hosted->>Core: rpc.call('my-plugin:…')
45+
```
46+
47+
## Register a remote dock
48+
49+
```ts
50+
import type { Plugin } from 'vite'
51+
52+
export function myPlugin(): Plugin {
53+
return {
54+
name: 'my-plugin',
55+
devtools: {
56+
setup(ctx) {
57+
ctx.docks.register({
58+
id: 'my-remote-tool',
59+
title: 'My Tool',
60+
icon: 'ph:cloud-duotone',
61+
type: 'iframe',
62+
url: 'https://example.com/devtools',
63+
remote: true,
64+
})
65+
},
66+
},
67+
}
68+
}
69+
```
70+
71+
That's the whole node-side change. The dock renders exactly like a normal iframe panel — with the connection descriptor invisibly appended to the URL.
72+
73+
### Options
74+
75+
```ts
76+
interface RemoteDockOptions {
77+
/** @default 'fragment' */
78+
transport?: 'fragment' | 'query'
79+
/** @default true */
80+
originLock?: boolean
81+
}
82+
83+
// in ctx.docks.register({ ... }):
84+
// remote: true
85+
// or:
86+
// remote: { transport: 'query', originLock: false }
87+
```
88+
89+
#### `transport`
90+
91+
- **`'fragment'` (default)** — the descriptor is appended as a URL fragment (`#vite-devtools-kit-connection=...`). Fragments are **not** sent to servers, **not** written to access logs, and **stripped from `Referer`** on outbound sub-resource requests. This is the safest place to carry an auth token.
92+
- **`'query'`** — the descriptor is appended as a query parameter (`?vite-devtools-kit-connection=...`). Use this when:
93+
- Your SPA router uses the fragment for navigation (and strips unknown fragments).
94+
- Your hosting platform or CDN rewrites URLs in a way that drops fragments.
95+
96+
The token **will** appear in server access logs and outbound `Referer` headers when transport is `'query'`. Only opt in if you control the analytics / log pipeline for the hosted origin.
97+
98+
#### `originLock`
99+
100+
When on (default), the WebSocket handshake is rejected if the browser's `Origin` header doesn't match the origin of the dock URL you registered. If the token leaks (e.g. logged to an external analytics tool that ingests URLs), a different origin can't use it to talk to the local dev server.
101+
102+
Turn off only when the same hosted app is served from multiple origins (e.g. preview deploys on `pr-123.preview.example.com`):
103+
104+
```ts
105+
ctx.docks.register({
106+
id: 'my-remote-tool',
107+
title: 'My Tool',
108+
icon: 'ph:cloud-duotone',
109+
type: 'iframe',
110+
url: 'https://example.com/devtools',
111+
remote: { originLock: false },
112+
})
113+
```
114+
115+
## Connect from the hosted page
116+
117+
Install `@vitejs/devtools-kit` as a dependency of your hosted page — it's browser-safe for this entrypoint:
118+
119+
```sh
120+
pnpm add @vitejs/devtools-kit
121+
```
122+
123+
Then, on page load:
124+
125+
```ts
126+
import { connectRemoteDevTools } from '@vitejs/devtools-kit/client'
127+
128+
const rpc = await connectRemoteDevTools()
129+
130+
// From here, use it like any other DevToolsRpcClient:
131+
const data = await rpc.call('my-plugin:get-data')
132+
```
133+
134+
`connectRemoteDevTools()` reads the descriptor from the current URL, opens the WebSocket, and resolves to a `DevToolsRpcClient` with `.call`, `.callEvent`, `.callOptional`, `.sharedState`, and the rest of the standard API documented in [RPC](./rpc).
135+
136+
If the page is loaded without a descriptor in the URL (someone opening `https://example.com/devtools` directly), the call throws. That's a useful signal — render a friendly "Open me through Vite DevTools" placeholder in that case:
137+
138+
```ts
139+
import { connectRemoteDevTools, parseRemoteConnection } from '@vitejs/devtools-kit/client'
140+
141+
if (!parseRemoteConnection()) {
142+
renderStandaloneLandingPage()
143+
}
144+
else {
145+
const rpc = await connectRemoteDevTools()
146+
renderConnectedUi(rpc)
147+
}
148+
```
149+
150+
### Advanced: custom URL / options
151+
152+
`connectRemoteDevTools` forwards any [`DevToolsRpcClientOptions`](./rpc) except `connectionMeta` and `authToken` (those come from the descriptor). Use this for RPC caching, custom `rpcOptions`, etc.
153+
154+
```ts
155+
const rpc = await connectRemoteDevTools({
156+
cacheOptions: { maxAge: 5000 },
157+
})
158+
```
159+
160+
For testing or non-browser environments you can pass an explicit URL or raw fragment/query string to `parseRemoteConnection`:
161+
162+
```ts
163+
parseRemoteConnection('https://example.com/p#vite-devtools-kit-connection=...')
164+
parseRemoteConnection('?vite-devtools-kit-connection=...')
165+
```
166+
167+
## Descriptor shape
168+
169+
The descriptor is a superset of [`ConnectionMeta`](./rpc), so `getDevToolsRpcClient({ connectionMeta })` accepts a parsed descriptor directly:
170+
171+
```ts
172+
interface RemoteConnectionInfo {
173+
v: 1
174+
backend: 'websocket'
175+
/** Full ws:// or wss:// URL. */
176+
websocket: string
177+
authToken: string
178+
/** Dev-server origin, e.g. http://localhost:5173. */
179+
origin: string
180+
}
181+
```
182+
183+
It's JSON-encoded and base64url-encoded, then appended to the iframe URL under the parameter name `vite-devtools-kit-connection`.
184+
185+
## Trust boundary
186+
187+
Enabling remote mode extends the following trust chain:
188+
189+
1. The user **installs your plugin** and opts into DevTools.
190+
2. Your plugin **declares a remote URL**.
191+
3. DevTools **hands the hosted origin a session token** scoped to that URL.
192+
193+
The session token is:
194+
195+
- **Pre-approved** — no interactive "trust this browser?" prompt fires. The user already agreed to the integration when they installed your plugin.
196+
- **Session-scoped** — stored in memory only, regenerated on every dev-server restart.
197+
- **Re-register-scoped** — calling `ctx.docks.register(..., true)` again for the same id revokes the previous token before allocating a new one. Any live WS clients using the old token receive `devtoolskit:internal:auth:revoked` and become untrusted.
198+
- **Origin-locked by default** — only connections whose `Origin` header matches the dock URL's origin are accepted.
199+
200+
Because the token rides in the URL (fragment or query), it should be treated as a session secret: don't log URLs to external services on the hosted page, and prefer `transport: 'fragment'` unless you have a specific reason not to.
201+
202+
## Build mode
203+
204+
The WebSocket server exists only in dev mode (`vite`), not in build mode (`vite build`). When `remote` is set, DevTools automatically hides the dock in build mode by defaulting its [`when` clause](./when-clauses) to `'mode != build'`. You can still set your own `when` if you need different behavior:
205+
206+
```ts
207+
ctx.docks.register({
208+
// ...
209+
remote: true,
210+
when: 'clientType == embedded', // overrides the default
211+
})
212+
```
213+
214+
## Related
215+
216+
- [Dock System](./dock-system) — the full list of dock types.
217+
- [RPC](./rpc) — the `DevToolsRpcClient` API.
218+
- [When Clauses](./when-clauses) — conditional dock visibility.

0 commit comments

Comments
 (0)