Skip to content

Commit 9a2d8fc

Browse files
authored
feat(kit)!: basic shared state (#147)
Breaking changes in this PR: 1. Change of the `ctx.rpc.broadcast` signature on Node.js side. 2. Change the `.get()` to `.value()` on `SharedState`
1 parent c26154d commit 9a2d8fc

File tree

27 files changed

+940
-72
lines changed

27 files changed

+940
-72
lines changed

alias.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ const root = fileURLToPath(new URL('.', import.meta.url))
66
const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url))
77

88
export const alias = {
9-
'@vitejs/devtools-rpc': r('rpc/src'),
109
'@vitejs/devtools-rpc/presets/ws/server': r('rpc/src/presets/ws/server.ts'),
1110
'@vitejs/devtools-rpc/presets/ws/client': r('rpc/src/presets/ws/client.ts'),
11+
'@vitejs/devtools-rpc': r('rpc/src'),
1212
'@vitejs/devtools-kit/client': r('kit/src/client/index.ts'),
1313
'@vitejs/devtools-kit/utils/events': r('kit/src/utils/events.ts'),
1414
'@vitejs/devtools-kit/utils/nanoid': r('kit/src/utils/nanoid.ts'),

docs/kit/index.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ DevTools Kit offers a complete toolkit for building DevTools integrations:
1919

2020
- **🔌 [Built-in RPC Layer](#remote-procedure-calls-rpc)**: Type-safe bidirectional communication between your Node.js server and browser clients, eliminating the need to set up WebSocket connections or message passing manually
2121

22+
- **🔗 [Shared State](#shared-state)**: Share data between server and client with automatic synchronization
23+
2224
- **🌐 Isomorphic Views Hosting**: Write your UI once and deploy it anywhere—as embedded floating panels, browser extension panels, standalone webpages, or even deployable SPAs for sharing build snapshots (work in progress).
2325

2426
## Why DevTools Kit?
@@ -418,6 +420,89 @@ rpc.client.register({
418420
})
419421
```
420422

423+
### Shared State
424+
425+
The DevTools Kit provides a built-in shared state system that enables you to share data between the server and client with automatic synchronization.
426+
427+
On the server side, you can get the shared state using `ctx.rpc.sharedState.get(name, options)`:
428+
429+
```ts {6-10}
430+
export default function myPlugin(): Plugin {
431+
return {
432+
name: 'my-plugin',
433+
devtools: {
434+
async setup(ctx) {
435+
// Get the shared state
436+
const state = await ctx.rpc.sharedState.get('my-plugin:state', {
437+
initialValue: {
438+
count: 0,
439+
name: 'John Doe',
440+
},
441+
})
442+
443+
// Use .value() to get the current state
444+
console.log(state.value()) // { count: 0, name: 'John Doe' }
445+
446+
setTimeout(() => {
447+
// Mutate the shared state, changes will be automatically synchronized to all the connected clients
448+
state.mutate((state) => {
449+
state.count += 1
450+
})
451+
}, 1000)
452+
},
453+
},
454+
}
455+
}
456+
```
457+
458+
<details>
459+
<summary>Type-safe shared state</summary>
460+
461+
The shared state is type-safe, you can get the state with the type of the initial value. To do so, you need to extend the `DevToolsRpcSharedStates` interface in your plugin's type definitions.
462+
463+
```ts [src/types.ts]
464+
import '@vitejs/devtools-kit'
465+
466+
declare module '@vitejs/devtools-kit' {
467+
interface DevToolsRpcSharedStates {
468+
'my-plugin:state': { count: number, name: string }
469+
}
470+
}
471+
```
472+
473+
</details>
474+
475+
On the client side, you can get the shared state using `client.rpc.sharedState.get(name)`:
476+
477+
```ts {6-10}
478+
import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client'
479+
480+
const client = await getDevToolsRpcClient()
481+
482+
const state = await client.rpc.sharedState.get('my-plugin:state')
483+
484+
console.log(state.value()) // { count: 0, name: 'John Doe' }
485+
486+
// Use .on('updated') to subscribe to changes
487+
state.on('updated', (newState) => {
488+
console.log(newState) // { count: 1, name: 'John Doe' }
489+
})
490+
```
491+
492+
For example, if you use Vue, you can wrap it into a reactive ref:
493+
494+
```ts {6-10}
495+
import { shallowRef } from 'vue'
496+
497+
const sharedState = await client.rpc.sharedState.get('my-plugin:state')
498+
const state = shallowRef(sharedState.value())
499+
sharedState.on('updated', (newState) => {
500+
state.value = newState
501+
})
502+
503+
// Now the `state` ref will be updated automatically when the shared state changes
504+
```
505+
421506
## References
422507

423508
The docs might not cover all the details, please help us to improve it by submitting PRs. And in the meantime, you can refer to the following existing DevTools integrations for reference (but note they might not always be up to date with the latest API changes):

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@
7979
"tsdown": "catalog:build",
8080
"typescript": "catalog:devtools",
8181
"unplugin-vue": "catalog:build",
82+
"unplugin-vue-router": "catalog:playground",
8283
"vite": "catalog:build",
8384
"vue": "catalog:frontend",
85+
"vue-router": "catalog:playground",
8486
"vue-tsc": "catalog:devtools"
8587
}
8688
}
Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,8 @@
1-
<script setup lang="ts">
2-
import HelloWorld from './components/HelloWorld.vue'
3-
</script>
4-
51
<template>
6-
<div>
7-
<a href="https://vite.dev" target="_blank">
8-
<img src="/vite.svg" class="logo" alt="Vite logo">
9-
</a>
10-
<a href="https://vuejs.org/" target="_blank">
11-
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
12-
</a>
13-
</div>
14-
<HelloWorld msg="Vite + Vue" />
2+
<Suspense>
3+
<RouterView />
4+
<template #fallback>
5+
<div>Loading...</div>
6+
</template>
7+
</Suspense>
158
</template>
16-
17-
<style scoped>
18-
.logo {
19-
height: 6em;
20-
padding: 1.5em;
21-
will-change: filter;
22-
transition: filter 300ms;
23-
}
24-
.logo:hover {
25-
filter: drop-shadow(0 0 2em #646cffaa);
26-
}
27-
.logo.vue:hover {
28-
filter: drop-shadow(0 0 2em #42b883aa);
29-
}
30-
</style>
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { createApp } from 'vue'
2+
import { createRouter, createWebHistory } from 'vue-router'
3+
import { routes } from 'vue-router/auto-routes'
4+
25
import App from './App.vue'
36
import './style.css'
47

5-
createApp(App).mount('#app')
8+
const router = createRouter({
9+
history: createWebHistory(),
10+
routes,
11+
})
12+
13+
createApp(App)
14+
.use(router)
15+
.mount('#app')
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script setup lang="ts">
2+
import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client'
3+
import { onMounted, shallowRef } from 'vue'
4+
5+
const stateRef = shallowRef<any>(undefined)
6+
const isTrustedRef = shallowRef<boolean | null>(null)
7+
8+
let increment = () => {}
9+
10+
onMounted(async () => {
11+
const client = await getDevToolsRpcClient()
12+
13+
isTrustedRef.value = client.isTrusted
14+
client.events.on('rpc:is-trusted:updated', (isTrusted) => {
15+
isTrustedRef.value = isTrusted
16+
})
17+
18+
const state = await client.sharedState.get('counter')
19+
20+
increment = () => {
21+
state.mutate((state) => {
22+
state.count++
23+
})
24+
}
25+
26+
stateRef.value = state.value()
27+
state.on('updated', (newState) => {
28+
stateRef.value = newState
29+
})
30+
})
31+
</script>
32+
33+
<template>
34+
<div>
35+
<h1>DevTools </h1>
36+
<div>{{ isTrustedRef }}</div>
37+
<pre>{{ stateRef }}</pre>
38+
<button @click="increment">
39+
Increment
40+
</button>
41+
</div>
42+
</template>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import HelloWorld from '../components/HelloWorld.vue'
3+
</script>
4+
5+
<template>
6+
<div>
7+
<a href="https://vite.dev" target="_blank">
8+
<img src="/vite.svg" class="logo" alt="Vite logo">
9+
</a>
10+
<a href="https://vuejs.org/" target="_blank">
11+
<img src="../assets/vue.svg" class="logo vue" alt="Vue logo">
12+
</a>
13+
</div>
14+
<HelloWorld msg="Vite + Vue" />
15+
</template>
16+
17+
<style scoped>
18+
.logo {
19+
height: 6em;
20+
padding: 1.5em;
21+
will-change: filter;
22+
transition: filter 300ms;
23+
}
24+
.logo:hover {
25+
filter: drop-shadow(0 0 2em #646cffaa);
26+
}
27+
.logo.vue:hover {
28+
filter: drop-shadow(0 0 2em #42b883aa);
29+
}
30+
</style>

packages/core/playground/typed-router.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ declare module 'vue-router/auto-routes' {
2323
* Route name map generated by unplugin-vue-router
2424
*/
2525
export interface RouteNamedMap {
26+
'/': RouteRecordInfo<
27+
'/',
28+
'/',
29+
Record<never, never>,
30+
Record<never, never>,
31+
| never
32+
>,
33+
'/devtools': RouteRecordInfo<
34+
'/devtools',
35+
'/devtools',
36+
Record<never, never>,
37+
Record<never, never>,
38+
| never
39+
>,
2640
}
2741

2842
/**
@@ -36,6 +50,18 @@ declare module 'vue-router/auto-routes' {
3650
* @internal
3751
*/
3852
export interface _RouteFileInfoMap {
53+
'src/pages/index.vue': {
54+
routes:
55+
| '/'
56+
views:
57+
| never
58+
}
59+
'src/pages/devtools.vue': {
60+
routes:
61+
| '/devtools'
62+
views:
63+
| never
64+
}
3965
}
4066

4167
/**

packages/core/playground/vite.config.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import process from 'node:process'
22
import Vue from '@vitejs/plugin-vue'
33
import UnoCSS from 'unocss/vite'
4+
import VueRouter from 'unplugin-vue-router/vite'
45
import { defineConfig } from 'vite'
56
import Tracer from 'vite-plugin-vue-tracer'
7+
import { alias } from '../../../alias'
68
// eslint-disable-next-line ts/ban-ts-comment
79
// @ts-ignore ignore the type error
810
import { DevToolsViteUI } from '../../vite/src/node'
911
import { DevTools } from '../src'
1012
import { buildCSS } from '../src/client/webcomponents/scripts/build-css'
1113

14+
declare module '@vitejs/devtools-kit' {
15+
interface DevToolsRpcSharedStates {
16+
counter: { count: number }
17+
}
18+
}
19+
1220
// https://vite.dev/config/
1321
export default defineConfig({
1422
define: {
1523
'import.meta.env.VITE_DEVTOOLS_LOCAL_DEV': JSON.stringify(process.env.VITE_DEVTOOLS_LOCAL_DEV),
1624
},
1725
base: './',
26+
resolve: {
27+
alias,
28+
},
1829
plugins: [
30+
VueRouter(),
1931
Vue(),
2032
{
2133
name: 'build-css',
@@ -38,7 +50,7 @@ export default defineConfig({
3850
{
3951
name: 'local',
4052
devtools: {
41-
setup(ctx) {
53+
async setup(ctx) {
4254
ctx.docks.register({
4355
title: 'Local',
4456
icon: 'logos:vue',
@@ -94,6 +106,14 @@ export default defineConfig({
94106
action: ctx.utils.createSimpleClientScript(() => {}),
95107
})
96108

109+
ctx.docks.register({
110+
id: 'devtools-tab',
111+
type: 'iframe',
112+
url: '/devtools/',
113+
title: 'DevTools',
114+
icon: 'ph:gear-duotone',
115+
})
116+
97117
ctx.docks.register({
98118
id: 'launcher',
99119
type: 'launcher',
@@ -123,10 +143,16 @@ export default defineConfig({
123143
},
124144
})
125145

126-
let count = 1
146+
const counterState = await ctx.rpc.sharedState.get('counter', {
147+
initialValue: { count: 1 },
148+
})
149+
127150
// eslint-disable-next-line unimport/auto-insert
128151
setInterval(() => {
129-
count = (count + 1) % 5
152+
counterState.mutate((current) => {
153+
current.count = (current.count + 1) % 5
154+
})
155+
const count = counterState.value().count
130156
ctx.docks.update({
131157
id: 'counter',
132158
type: 'action',

packages/core/src/node/context.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,27 @@ export async function createDevToolsContext(
4646

4747
// Register hosts side effects
4848
docksHost.events.on('dock:entry:updated', debounce(() => {
49-
rpcHost.broadcast('vite:internal:docks:updated')
49+
rpcHost.broadcast({
50+
method: 'vite:internal:docks:updated',
51+
args: [],
52+
})
5053
}, 10))
5154
terminalsHost.events.on('terminal:session:updated', debounce(() => {
52-
rpcHost.broadcast('vite:internal:terminals:updated')
55+
rpcHost.broadcast({
56+
method: 'vite:internal:terminals:updated',
57+
args: [],
58+
})
5359
// New terminals might affect the visibility of the terminals dock entry, we trigger it here as well
54-
rpcHost.broadcast('vite:internal:docks:updated')
60+
rpcHost.broadcast({
61+
method: 'vite:internal:docks:updated',
62+
args: [],
63+
})
5564
}, 10))
5665
terminalsHost.events.on('terminal:session:stream-chunk', (data) => {
57-
rpcHost.broadcast('vite:internal:terminals:stream-chunk', data)
66+
rpcHost.broadcast({
67+
method: 'vite:internal:terminals:stream-chunk',
68+
args: [data],
69+
})
5870
})
5971

6072
// Register plugins

0 commit comments

Comments
 (0)