diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b26e7ab1f..54b6c2ec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,30 @@ jobs: run: nr knip continue-on-error: true + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Install pnpm + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b + with: + install: false + + - name: Set node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: lts/* + + - name: Setup + run: npm i -g @antfu/ni + + - name: Install + run: nci + + - name: Build docs + run: nr docs:build + test: runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/src/ci.yml b/.github/workflows/src/ci.yml index 6da4fb39a..b69f2e60f 100644 --- a/.github/workflows/src/ci.yml +++ b/.github/workflows/src/ci.yml @@ -48,6 +48,33 @@ jobs: run: nr knip continue-on-error: true + docs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b + with: + install: false + + - name: Set node + uses: actions/setup-node@v5 + with: + node-version: lts/* + + - name: Setup + run: npm i -g @antfu/ni + + - name: Install + run: nci + + # Mirrors the Netlify build command so a broken docs build is caught in + # CI instead of silently leaving the published site on a stale deploy. + - name: Build docs + run: nr docs:build + test: runs-on: ${{ matrix.os }} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ad95f26c3..a625dda0b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,16 +25,14 @@ const DevToolsKitNav = [ { text: 'Introduction', link: '/kit/' }, { text: 'DevTools Plugin', link: '/kit/devtools-plugin' }, { text: 'Dock System', link: '/kit/dock-system' }, + { text: 'Create Plugin from Devframe', link: '/kit/create-plugin-from-devframe' }, { text: 'Client Script & Context', link: '/kit/client-context' }, { text: 'RPC', link: '/kit/rpc' }, { text: 'Shared State', link: '/kit/shared-state' }, - { text: 'Streaming', link: '/kit/streaming' }, { text: 'Commands', link: '/kit/commands' }, - { text: 'When Clauses', link: '/kit/when-clauses' }, { text: 'Messages & Notifications', link: '/kit/messages' }, { text: 'Structured Diagnostics', link: '/kit/diagnostics' }, { text: 'Terminals & Processes', link: '/kit/terminals' }, - { text: 'Remote Client', link: '/kit/remote-client' }, { text: 'Examples', link: '/kit/examples' }, ] @@ -125,23 +123,7 @@ export default extendConfig(withMermaid(defineConfig({ }, { text: 'DevTools Kit', - items: [ - { text: 'Introduction', link: '/kit/' }, - { text: 'DevTools Plugin', link: '/kit/devtools-plugin' }, - { text: 'Dock System', link: '/kit/dock-system' }, - { text: 'Client Script & Context', link: '/kit/client-context' }, - { text: 'RPC', link: '/kit/rpc' }, - { text: 'Shared State', link: '/kit/shared-state' }, - { text: 'Streaming', link: '/kit/streaming' }, - { text: 'Commands', link: '/kit/commands' }, - { text: 'When Clauses', link: '/kit/when-clauses' }, - { text: 'Messages', link: '/kit/messages' }, - { text: 'Diagnostics', link: '/kit/diagnostics' }, - { text: 'JSON Render', link: '/kit/json-render' }, - { text: 'Terminals', link: '/kit/terminals' }, - { text: 'Remote Client', link: '/kit/remote-client' }, - { text: 'Examples', link: '/kit/examples' }, - ], + items: DevToolsKitNav, }, { text: 'Error Reference', diff --git a/docs/errors/DTK0033.md b/docs/errors/DTK0033.md index e61151a02..7921c9081 100644 --- a/docs/errors/DTK0033.md +++ b/docs/errors/DTK0033.md @@ -20,7 +20,7 @@ Running the dev server against a project whose `node_modules` directory is mount ## Fix -Ensure the project's `node_modules` directory is writable by the user running the dev server. If `node_modules` must stay read-only, use `visibility: 'normal'` (the default) in the DevTools plugin options to always show the docks instead of relying on the persisted flag. +Ensure the project's `node_modules` directory is writable by the user running the dev server. If `node_modules` must stay read-only, use `embeddedVisibility: 'normal'` (the default) in the DevTools plugin options to always show the docks instead of relying on the persisted flag. ## Source diff --git a/docs/guide/index.md b/docs/guide/index.md index 14f5caa34..a2f4c1ae8 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -121,7 +121,7 @@ import '@vitejs/devtools/client/inject-passive' import '@vitejs/devtools/client/inject-hidden' ``` -See [Client Script & Context](/kit/client-context#client-script-not-injected) for how injection works and the full troubleshooting checklist. +See the [Devframe client guide](https://devfra.me/guide/client) for how injection and the browser RPC client work. ### Building with the app diff --git a/docs/kit/client-context.md b/docs/kit/client-context.md index 12e61bcc5..8d0ca594c 100644 --- a/docs/kit/client-context.md +++ b/docs/kit/client-context.md @@ -2,110 +2,39 @@ outline: deep --- -# Client Script & Client Context +# Client Script & Context -In embedded mode, Vite DevTools injects a small **client script** into your app's page. The script boots the dock and publishes the **client context** — the object that every client-side surface (dock client scripts, action buttons, your own app code) uses to talk to DevTools. +In embedded mode, Vite DevTools injects a small **client script** into your app's page. It boots the dock and publishes the **client context** — the object every client-side surface (dock action scripts, custom renderers, your own app code) uses to talk to DevTools. ## The client script -The client script is the browser entry of Vite DevTools (published as `@vitejs/devtools/client/inject`). When it runs in the host page it: +The client script (`@vitejs/devtools/client/inject`) connects an RPC client to the DevTools server at `/__devtools/`, builds the client context on top of it, and mounts the embedded dock. The `DevTools()` plugin injects it automatically: -1. Connects an RPC client to the DevTools server at `/__devtools/` (WebSocket in dev mode). -2. Builds the `DevToolsClientContext` — dock entries, panel state, commands, when-clauses — on top of that RPC client. -3. Publishes the context to a global slot, so `getDevToolsClientContext()` can read it from anywhere in the page. -4. Mounts the embedded dock web component into `document.body`. +- **During `vite dev`** via `transformIndexHtml`. A production build ships the same bootstrap through [`build.withApp`](/guide/#building-with-the-app). +- **In client environments only** — SSR builds and server code stay untouched. +- **In top-level windows only** — inside an iframe the script exits, so a page never mounts a second dock. -### How injection works - -The `DevTools()` plugin injects the script through Vite's `transformIndexHtml` hook. During `vite dev`, every HTML page served by Vite receives a module script that imports the injection virtual module, which in turn loads the client entry. The plugin picks the entry from the project's resolved visibility: `@vitejs/devtools/client/inject` (docks shown immediately) by default, `@vitejs/devtools/client/inject-passive` (docks hidden until Shift+Alt+D, then remembered) for passive mode, or `@vitejs/devtools/client/inject-hidden` (revealed per session, never remembered) for hidden mode: - -```mermaid -sequenceDiagram - participant Vite as Vite Dev Server - participant Page as Host Page - participant Server as DevTools Server - - Vite->>Page: transformIndexHtml appends
import "virtual:vite-devtools-injection" - Page->>Page: loads @vitejs/devtools/client/inject - Page->>Server: RPC connect (/__devtools/) - Page->>Page: publish client context, mount dock -``` - -Automatic injection is scoped to where the embedded client makes sense: - -- **Automatic HTML injection during development** — `transformIndexHtml` mounts the embedded client in the dev server. A production build can ship the same embedded bootstrap through [`build.withApp`](/guide/#building-with-the-app). -- **Client environments only** — SSR builds and server code stay untouched. -- **Top-level windows only** — inside an iframe (including DevTools' own iframe panels) the script logs `[VITE DEVTOOLS] Skipping in iframe` and exits, so a page never mounts a second dock. +Which entry is injected follows the resolved `embeddedVisibility`: `inject` (shown immediately), `inject-passive` (hidden until Shift+Alt+D, then remembered), or `inject-hidden` (revealed per session). For projects without an HTML entry, import one of these entries yourself — see [Getting Started](/guide/#projects-without-an-html-entry). ## The client context -`DevToolsClientContext` is the client-side counterpart of the [node context](./devtools-plugin): one object carrying everything a client surface needs. - -| Property | Description | -|----------|-------------| -| `rpc` | The RPC client — `call()` server functions, register [client-side functions](/kit/rpc#client-side-functions), access shared state and streaming. | -| `clientType` | `'embedded'` (dock inside your app) or `'standalone'` (independent DevTools page). | -| `docks` | Dock entries and selection — `entries`, `selected`, `switchEntry()`, `toggleEntry()`. | -| `panel` | Dock panel state: position, size, drag/resize flags. | -| `commands` | The [command palette](./commands): `register()`, `execute()`, keybindings. | -| `when` | The [when-clause](./when-clauses) evaluation context. | - -### Accessing the context - -From anywhere in the host page, use `getDevToolsClientContext()`. It returns `undefined` until the client script finishes initializing: +`DevToolsClientContext` is the client-side counterpart of the [node context](./devtools-plugin). Read it anywhere in the host page with `getDevToolsClientContext()`, which returns `undefined` until the script finishes initializing: ```ts import { getDevToolsClientContext } from '@vitejs/devtools-kit/client' const ctx = getDevToolsClientContext() if (ctx) { - const modules = await ctx.rpc.call('my-plugin:get-modules') - ctx.docks.switchEntry('my-plugin') -} -``` - -[Dock client scripts](/kit/dock-system#client-script) — action buttons and custom renderers — receive the context directly as their argument, extended with two dock-scoped extras: `current` (this entry's state, DOM elements, and events) and `messages` (a [messages client](./messages) scoped to the entry): - -```ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default function setup(ctx: DockClientScriptContext) { - ctx.current.events.on('entry:activated', async () => { - const data = await ctx.rpc.call('my-plugin:get-modules') - ctx.messages.info(`Loaded ${data.length} modules`) - }) + const data = await ctx.rpc.call('my-plugin:get-modules') } ``` -Iframe panels run in their own document, so they create their own RPC client with [`getDevToolsRpcClient()`](/kit/rpc#in-iframe-pages) instead — the connection details are discovered automatically from the parent window. - -## Troubleshooting - -### Client script not injected - -Symptoms: the dock never appears, `getDevToolsClientContext()` always returns `undefined`, and the browser console has no `[VITE DEVTOOLS] Client injected` log. - -Injection rides on Vite's `transformIndexHtml` hook, so it requires an HTML page that Vite itself serves and transforms. Setups where the HTML comes from elsewhere skip it: - -- **Backend integration** — Rails, Laravel, Django, or any server rendering its own HTML while Vite only serves assets. -- **Middleware mode** — an app framework embedding Vite's dev server without serving `index.html` through it. -- **JS-only entries** — projects whose entry point is a script rather than an HTML file. - -The fix is to import the client injector manually from a browser entry (`main.ts`, `entry.client.ts`): - -```ts -import '@vitejs/devtools/client/inject' -``` - -Keep the import out of server-only and shared SSR files, and use it only when HTML injection doesn't happen — combining both mounts the client twice. To keep the client out of production bundles, guard it as a dev-only dynamic import: - -```ts -if (import.meta.env.DEV) - import('@vitejs/devtools/client/inject') -``` - -### Other checks +| Property | Description | +|----------|-------------| +| `rpc` | The [RPC](./rpc) client — call server functions, register client functions, reach shared state | +| `clientType` | `'embedded'` (dock inside your app) or `'standalone'` (independent DevTools page) | +| `docks` | Dock entries and selection — `entries`, `selected`, `switchEntry()` | +| `panel` | Dock panel state: position, size, drag/resize flags | +| `commands` | The [command palette](./commands): `register()`, `execute()`, keybindings | -- **Integration enabled?** Use Vite's `devtools` config, or register the `DevTools()` plugin from `@vitejs/devtools` manually. -- **Build output?** Enable build-time collection with `devtools: { apply: 'build' }`; use [`build.withApp`](/guide/#building-with-the-app) when the generated app should include the embedded client. -- **Dock appears but asks for authorization?** That's client trust, a separate layer from injection — see [DTK0008](/errors/DTK0008) and the `devtools.clientAuth` option. +The browser RPC client and connection modes are documented in the [Devframe client guide](https://devfra.me/guide/client). diff --git a/docs/kit/commands.md b/docs/kit/commands.md index 484566aec..5088271fe 100644 --- a/docs/kit/commands.md +++ b/docs/kit/commands.md @@ -2,36 +2,15 @@ outline: deep --- -# Commands & Command Palette +# Commands -DevTools Kit's commands system lets plugins register executable commands on the server and client. Users discover and run them through the built-in command palette, and rebind keyboard shortcuts to taste. +Commands are executable actions plugins register on the server or client. Users discover and run them through the built-in command palette and can rebind their keyboard shortcuts. The palette merges commands from every integration. -## Overview +Commands are a hub concept shared across host frameworks — see the [Devframe hub docs](https://devfra.me/guide/hub) for the full model. This page shows registering them from a Vite plugin. -```mermaid -sequenceDiagram - participant Plugin as Vite Plugin (Server) - participant Client as Browser Client - participant Palette as Command Palette +## Server commands - Plugin->>Plugin: ctx.commands.register({ id, handler }) - Note over Plugin: Commands synced via shared state - Plugin-->>Client: Server commands available - - Client->>Client: register({ id, action }) - Note over Client: Client commands registered locally - - Palette->>Palette: Merge server + client commands - Palette->>Client: User selects command - Client->>Plugin: RPC call (server commands) - Client->>Client: Direct action (client commands) -``` - -## Server-side commands - -### Defining commands - -Use `defineCommand` and register through `ctx.commands.register()`: +Define with `defineCommand` and register in `devtools.setup`: ```ts import { defineCommand } from '@vitejs/devtools-kit' @@ -46,11 +25,7 @@ const clearCache = defineCommand({ await fs.rm('.cache', { recursive: true }) }, }) -``` - -Register it in your plugin setup: -```ts const plugin: Plugin = { devtools: { setup(ctx) { @@ -60,276 +35,20 @@ const plugin: Plugin = { } ``` -### Command options +Server command handlers run on the Node side; the palette bridges the selection to an RPC call. -| Field | Type | Description | -|-------|------|-------------| -| `id` | `string` | **Required.** Unique namespaced ID (e.g. `my-plugin:action`) | -| `title` | `string` | **Required.** Human-readable title shown in the palette | -| `description` | `string` | Optional description text | -| `icon` | `string` | Iconify icon string (e.g. `ph:trash-duotone`) | -| `category` | `string` | Category for grouping | -| `showInPalette` | `boolean \| 'without-children'` | Whether to show in command palette (default: `true`). `'without-children'` shows the command but doesn't flatten children into search — they're only accessible via drill-down. | -| `when` | `string` | Conditional visibility expression (see [When Clauses](/kit/when-clauses)) | -| `keybindings` | `DevToolsCommandKeybinding[]` | Default keyboard shortcuts | -| `handler` | `Function` | Server-side handler. Optional if the command is a group for children. | -| `children` | `DevToolsServerCommandInput[]` | Static sub-commands (two levels max) | +## Client commands -### Command handle - -`register()` returns a handle for live updates: +Register client-side actions on the [client context](./client-context) — they run directly in the browser: ```ts -const handle = ctx.commands.register({ - id: 'my-plugin:status', - title: 'Show Status', - handler: () => { /* ... */ }, -}) - -// Update later -handle.update({ title: 'Show Status (3 items)' }) +const ctx = getDevToolsClientContext()! -// Remove -handle.unregister() -``` - -## Sub-commands - -Commands can have static children, forming a two-level hierarchy. Selecting a parent in the palette drills into its children. - -```ts ctx.commands.register({ - id: 'git', - title: 'Git', - icon: 'ph:git-branch-duotone', - category: 'tools', - // No handler — group-only parent - children: [ - { - id: 'git:commit', - title: 'Commit', - icon: 'ph:check', - keybindings: [{ key: 'Mod+Shift+G' }], - handler: async () => { /* ... */ }, - }, - { - id: 'git:push', - title: 'Push', - handler: async () => { /* ... */ }, - }, - { - id: 'git:pull', - title: 'Pull', - handler: async () => { /* ... */ }, - }, - ], -}) -``` - -In the palette, users see **Git** → select → drill into **Commit**, **Push**, **Pull**. Sub-commands with keybindings (like `Mod+Shift+G` above) execute directly from the shortcut without opening the palette. - -Each child needs a globally unique `id`; the recommended pattern is `parentId:childAction` (`git:commit`). - -## Keyboard shortcuts - -### Defining shortcuts - -Add default keybindings when registering a command: - -```ts -ctx.commands.register({ - id: 'my-plugin:toggle-overlay', - title: 'Toggle Overlay', - keybindings: [ - { key: 'Mod+Shift+O' }, - ], - handler: () => { /* ... */ }, -}) -``` - -### Key format - -Use `Mod` as a platform-aware modifier — it maps to `Cmd` on macOS and `Ctrl` on other platforms. - -| Key string | macOS | Windows/Linux | -|------------|-------|---------------| -| `Mod+K` | `Cmd+K` | `Ctrl+K` | -| `Mod+Shift+P` | `Cmd+Shift+P` | `Ctrl+Shift+P` | -| `Alt+N` | `Option+N` | `Alt+N` | - -### Conditional `when` clauses - -Commands support a `when` expression for conditional visibility and activation: - -```ts -ctx.commands.register(defineCommand({ - id: 'my-plugin:embedded-only', - title: 'Embedded-Only Action', - when: 'clientType == embedded', - handler: async () => { /* ... */ }, -})) -``` - -When set, the command shows in the palette and triggers via its shortcut only while the expression evaluates to `true`. The grammar covers `==`, `!=`, `&&`, `||`, `!`, bare truthy, literal `true`/`false`, and namespaced keys like `vite.mode` — see [When Clauses](/kit/when-clauses) for the full reference. - -### User overrides - -Users customise shortcuts in the DevTools Settings page under **Keyboard Shortcuts**. Overrides land in shared state and persist across sessions; setting an empty array disables a shortcut. - -### Shortcut editor - -The Settings page includes an inline shortcut editor with: - -- **Key capture** — click the input and press any key combination. -- **Modifier toggles** — toggle Cmd/Ctrl, Alt, Shift individually. -- **Conflict detection** — warns when a shortcut conflicts with common browser shortcuts (`Cmd+T` → "Open new tab", `Cmd+W` → "Close tab"), with another registered command, or with a weak single-key combo without modifiers. - -`KNOWN_BROWSER_SHORTCUTS` is exported from `@vitejs/devtools-kit` and maps each key combination to a human-readable description. - -## Command palette - -`Mod+K` (or `Ctrl+K` on Windows/Linux) toggles the built-in palette. It offers: - -- **Fuzzy search** across all registered commands (including sub-commands). -- **Keyboard navigation** — arrow keys, Enter to select, Escape to close. -- **Drill-down** — commands with children show breadcrumb navigation. -- **Server-command execution** — RPC with a loading indicator. -- **Dynamic sub-menus** — client commands can return sub-items at runtime. - -### Embedded vs standalone - -In embedded mode, the palette floats over the user's app as part of the DevTools overlay. In standalone mode, it appears as a modal dialog in the standalone DevTools window. - -## Client-side commands - -Client commands register in the webcomponent context and execute directly in the browser: - -```ts -// From within the DevTools client context -context.commands.register({ - id: 'devtools:theme', - source: 'client', - title: 'Theme', - icon: 'ph:palette-duotone', - children: [ - { - id: 'devtools:theme:light', - source: 'client', - title: 'Light', - action: () => setTheme('light'), - }, - { - id: 'devtools:theme:dark', - source: 'client', - title: 'Dark', - action: () => setTheme('dark'), - }, - ], + id: 'my-plugin:toggle-grid', + title: 'Toggle Layout Grid', + handler: () => document.body.classList.toggle('debug-grid'), }) ``` -Client commands can also return dynamic sub-items: - -```ts -context.commands.register({ - id: 'devtools:docs', - source: 'client', - title: 'Documentation', - action: async () => { - const docs = await fetchDocs() - return docs.map(doc => ({ - id: `docs:${doc.slug}`, - source: 'client' as const, - title: doc.title, - action: () => window.open(doc.url, '_blank'), - })) - }, -}) -``` - -## Executing programmatically - -Code with access to the kit context can trigger a command by id: - -```ts -await ctx.commands.execute('my-plugin:clear-cache') - -// With arguments: -await ctx.commands.execute('my-plugin:open-file', '/src/main.ts') -``` - -`execute` searches both top-level commands and children, and throws when the command isn't registered or has no handler. - -## Listing & introspection - -The host exposes a `list()` method returning serializable command entries (without handlers) — useful for custom palette UIs or exporting the current command set: - -```ts -const commands = ctx.commands.list() -for (const cmd of commands) { - console.log(cmd.id, cmd.title, cmd.keybindings) -} -``` - -Subscribe to lifecycle events to react to registrations: - -```ts -ctx.commands.events.on('command:registered', (cmd) => { - console.log('new command:', cmd.id) -}) -ctx.commands.events.on('command:unregistered', (id) => { - console.log('removed:', id) -}) -``` - -## Complete example - -::: code-group - -```ts [plugin.ts] -/// -import type { Plugin } from 'vite' -import { defineCommand } from '@vitejs/devtools-kit' - -export default function myPlugin(): Plugin { - return { - name: 'my-plugin', - - devtools: { - setup(ctx) { - // Simple command - ctx.commands.register(defineCommand({ - id: 'my-plugin:restart', - title: 'Restart Dev Server', - icon: 'ph:arrow-clockwise-duotone', - keybindings: [{ key: 'Mod+Shift+R' }], - handler: async () => { - await ctx.viteServer?.restart() - }, - })) - - // Command with sub-commands - ctx.commands.register(defineCommand({ - id: 'my-plugin:cache', - title: 'Cache', - icon: 'ph:database-duotone', - children: [ - { - id: 'my-plugin:cache:clear', - title: 'Clear Cache', - handler: async () => { /* ... */ }, - }, - { - id: 'my-plugin:cache:inspect', - title: 'Inspect Cache', - handler: async () => { /* ... */ }, - }, - ], - })) - }, - }, - } -} -``` - -::: +Bind a keybinding, gate visibility with a [when clause](https://devfra.me/references/when-clauses), or nest children through the same definition — see the hub docs for those fields. diff --git a/docs/kit/create-plugin-from-devframe.md b/docs/kit/create-plugin-from-devframe.md new file mode 100644 index 000000000..06385a77b --- /dev/null +++ b/docs/kit/create-plugin-from-devframe.md @@ -0,0 +1,48 @@ +--- +outline: deep +--- + +# Create Plugin from Devframe + +A [Devframe definition](https://devfra.me/guide/devframe-definition) is a portable devtool — the same definition runs standalone, as a CLI, through a coding agent, or inside any host framework. `createPluginFromDevframe` is the bridge that mounts one as a Vite plugin inside Vite DevTools. + +```ts +// vite.config.ts +import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' +import devtool from './my-devtool' + +export default { + plugins: [createPluginFromDevframe(devtool)], +} +``` + +In its `devtools.setup`, the plugin mounts the devframe's SPA, registers an iframe dock entry synthesized from the definition's `id` / `name` / `icon` / `basePath`, and runs the devframe's own `setup(ctx)`. You author the tool once with Devframe; Kit handles the Vite wiring. + +## Options + +```ts +createPluginFromDevframe(devtool, { + // Vite plugin name. Defaults to `devframe:${d.id}`. + name: 'my-devtool', + + // Mount path. Defaults to `d.basePath` or `/__${d.id}/`. + base: '/__my-devtool/', + + // Customize the auto-synthesized iframe dock entry — category, icon + // override, `when` visibility, etc. `id`, `type`, and `url` stay derived. + dock: { category: 'app' }, + + // Kit-only setup, run after the devframe's own setup and after the dock + // entry is registered. Use it for hub features that should not live in the + // portable definition — commands, terminals, extra dock entries. + setup(ctx) { + ctx.commands.register({ + id: 'my-devtool:clear-cache', + title: 'Clear Cache', + handler: () => { /* ... */ }, + }) + }, +}) +``` + +Keep the portable definition free of Vite assumptions: anything that needs the hub belongs in `opts.setup`, not in the devframe's own `setup`. See [Devframe adapters](https://devfra.me/adapters) for the other ways a definition can be deployed. diff --git a/docs/kit/devtools-plugin.md b/docs/kit/devtools-plugin.md index 9221f3ccd..30701919b 100644 --- a/docs/kit/devtools-plugin.md +++ b/docs/kit/devtools-plugin.md @@ -4,9 +4,9 @@ outline: deep # DevTools Plugin -A DevTools plugin is a Vite plugin with one extra hook: `devtools.setup(ctx)`. The hook receives the kit-augmented context (`KitNodeContext`) — RPC, views, and the four hub subsystems Kit owns: `docks`, `terminals`, `messages`, `commands`. +A DevTools plugin is a Vite plugin with one extra hook: `devtools.setup(ctx)`. It runs once during Vite server initialization, only when DevTools is enabled, and receives the Vite-augmented context — RPC, views, shared state, and the hub subsystems (`docks`, `terminals`, `messages`, `commands`) — plus Vite's own `viteConfig` and `viteServer`. -This page covers the direct hook approach. To bring in a portable [Devframe](https://devfra.me/guide/) app instead, see [`createPluginFromDevframe`](https://devfra.me/guide/adapters#kit) — Kit auto-mounts the SPA, derives the iframe dock entry from `id` / `name` / `icon` / `basePath`, then runs an optional kit-only `setup` for hub features. +To bring in a portable [Devframe](https://devfra.me/guide/) app instead of writing the hook by hand, see [Create Plugin from Devframe](./create-plugin-from-devframe). ## Installation @@ -30,7 +30,7 @@ yarn add -D @vitejs/devtools-kit ## Basic setup -Add the triple-slash reference to augment Vite's `Plugin` interface with the `devtools` property: +Add the triple-slash reference to augment Vite's `Plugin` interface with the `devtools` property, then register a dock entry in `setup`: ```ts /// @@ -39,18 +39,8 @@ import type { Plugin } from 'vite' export default function myPlugin(): Plugin { return { name: 'my-plugin', - - // Regular Vite plugin hooks - configResolved(config) { - // ... - }, - - // DevTools setup - only called when DevTools is enabled devtools: { setup(ctx) { - console.log('DevTools setup for my-plugin') - - // Register dock entries, RPC functions, etc. ctx.docks.register({ id: 'my-plugin', title: 'My Plugin', @@ -64,55 +54,38 @@ export default function myPlugin(): Plugin { } ``` -`devtools.setup` runs once during Vite server initialization, when DevTools is enabled. - -## DevTools context - -The `setup` function receives a `ViteDevToolsNodeContext` providing access to every DevTools API: - -```ts -const plugin: Plugin = { - devtools: { - setup(ctx) { - // ctx contains everything you need - } - } -} -``` +## The context -### Available properties +`setup` receives a `ViteDevToolsNodeContext`. The Vite-specific slots are: | Property | Type | Description | |----------|------|-------------| -| `ctx.docks` | `DocksHost` | Register and manage [dock entries](./dock-system) | -| `ctx.views` | `ViewsHost` | Host static files for your DevTools UI | -| `ctx.rpc` | `RpcHost` | Register [RPC functions](./rpc) and broadcast to clients | | `ctx.viteConfig` | `ResolvedConfig` | The resolved Vite configuration | -| `ctx.viteServer` | `ViteDevServer \| undefined` | Vite dev server instance, present in dev mode | +| `ctx.viteServer` | `ViteDevServer \| undefined` | The dev server instance, present in dev mode | | `ctx.mode` | `'dev' \| 'build'` | Current mode | | `ctx.cwd` | `string` | Current working directory | | `ctx.workspaceRoot` | `string` | Workspace root directory | +| `ctx.views` | `ViewsHost` | Host static files for your UI (`hostStatic`) | +| `ctx.docks` | `DocksHost` | Register [dock entries](./dock-system) | -### Example: accessing Vite config +The context also carries the subsystems from the hub: [`ctx.rpc`](./rpc), [`ctx.rpc.sharedState`](./shared-state), [`ctx.terminals`](./terminals), [`ctx.commands`](./commands), [`ctx.messages`](./messages), and [`ctx.diagnostics`](./diagnostics). ```ts const plugin: Plugin = { devtools: { setup(ctx) { console.log('Root:', ctx.viteConfig.root) - console.log('Mode:', ctx.mode) - if (ctx.viteServer) { console.log('Dev server is running') } - } - } + }, + }, } ``` -## Hosting static files +## Hosting a static UI -For a pre-built UI (Vue/React SPA, etc.), serve it with `ctx.views.hostStatic()`: +For a pre-built SPA (Vue/React/Svelte/etc.), serve it with `ctx.views.hostStatic()` and point an iframe dock entry at the same route. DevTools handles dev-server middleware and copies the files into the build output at build time. ```ts import { fileURLToPath } from 'node:url' @@ -120,15 +93,9 @@ import { fileURLToPath } from 'node:url' const plugin: Plugin = { devtools: { setup(ctx) { - // Resolve path to your built client files - const clientPath = fileURLToPath( - new URL('../dist/client', import.meta.url) - ) - - // Host at a specific route + const clientPath = fileURLToPath(new URL('../dist/client', import.meta.url)) ctx.views.hostStatic('/__my-plugin/', clientPath) - // Register as a dock entry ctx.docks.register({ id: 'my-plugin', title: 'My Plugin', @@ -136,16 +103,14 @@ const plugin: Plugin = { type: 'iframe', url: '/__my-plugin/', }) - } - } + }, + }, } ``` -DevTools handles dev-server middleware and copies the static files into the output directory at build time. - ## Complete example -A plugin with a dock entry and an RPC function that exposes module data: +A plugin that hosts a UI, exposes module data over RPC, and surfaces a dock entry: ```ts /// @@ -154,28 +119,20 @@ import { fileURLToPath } from 'node:url' import { defineRpcFunction } from '@vitejs/devtools-kit' export default function myAnalyzerPlugin(): Plugin { - const analyzedModules = new Map() + const analyzedModules = new Map() return { name: 'my-analyzer', - // Collect data during transforms transform(code, id) { - analyzedModules.set(id, { - size: code.length, - imports: [], // Parse imports here - }) + analyzedModules.set(id, { size: code.length }) }, devtools: { setup(ctx) { - // Host the UI - const clientPath = fileURLToPath( - new URL('../dist/client', import.meta.url) - ) + const clientPath = fileURLToPath(new URL('../dist/client', import.meta.url)) ctx.views.hostStatic('/__my-analyzer/', clientPath) - // Register dock entry ctx.docks.register({ id: 'my-analyzer', title: 'Module Analyzer', @@ -184,19 +141,15 @@ export default function myAnalyzerPlugin(): Plugin { url: '/__my-analyzer/', }) - // Register RPC function to fetch data ctx.rpc.register( defineRpcFunction({ name: 'my-analyzer:get-modules', type: 'query', setup: () => ({ - handler: async () => { - return Array.from(analyzedModules.entries()).map( - ([id, data]) => ({ id, ...data }) - ) - }, + handler: async () => + Array.from(analyzedModules, ([id, data]) => ({ id, ...data })), }), - }) + }), ) }, }, @@ -204,12 +157,14 @@ export default function myAnalyzerPlugin(): Plugin { } ``` +RPC function shapes and the client-side call API are covered in [RPC](./rpc). + ## Debugging with the inspector -Vite DevTools ships the official `@devframes/plugin-inspect` inspector as a built-in panel, enabled by default with `builtinDevTools`. It shows registered RPC functions, dock entries, client scripts, and DevTools-enabled plugins — handy when verifying that everything you registered actually shows up. Open the "Inspect" dock; no extra install needed. +Vite DevTools ships the official `@devframes/plugin-inspect` inspector as a built-in panel (enabled by default with `builtinDevTools`). It shows registered RPC functions, dock entries, client scripts, and DevTools-enabled plugins — handy for verifying that what you registered actually shows up. Open the **Inspect** dock; no extra install needed. ## Next steps -- **[Dock System](./dock-system)** — iframe panels, action buttons, custom renderers, launchers, json-render specs. -- **[RPC](./rpc)** — bidirectional server-client communication. -- **[Shared State](./shared-state)** — patch-synced state across every connected client. +- **[Dock System](./dock-system)** — dock entry types and install launchers. +- **[RPC](./rpc)** and **[Shared State](./shared-state)** — server ↔ client communication. +- **[Create Plugin from Devframe](./create-plugin-from-devframe)** — mount a portable devframe. diff --git a/docs/kit/diagnostics.md b/docs/kit/diagnostics.md index 5d1d66572..686cbb218 100644 --- a/docs/kit/diagnostics.md +++ b/docs/kit/diagnostics.md @@ -1,35 +1,12 @@ -# Structured Diagnostics - -`ctx.diagnostics` is a thin layer over [`nostics`](https://github.com/vercel-labs/nostics) that lets DevTools plugins register coded errors and warnings into a shared registry without depending on `nostics` directly. Use it for author-defined coded diagnostics — errors, warnings, deprecations — that carry a stable code, a documentation URL, and a structured payload. For free-form runtime output that should appear in the DevTools UI, use [`ctx.messages`](./messages). - -| Surface | Purpose | Example | -|---------|---------|---------| -| `ctx.diagnostics` | Coded errors and warnings emitted from node-side plugin code | `MYP0001: Plugin foo not configured` | -| [`ctx.messages`](./messages) | Free-form, user-facing notifications shown in the Messages panel | `'Audit complete — 3 issues found'` | - -## API shape - -```ts -interface DevToolsDiagnosticsHost { - /** - * Proxy-backed lookup of every registered code by name. Each entry is a - * `nostics` `DiagnosticHandle` — a callable that builds a diagnostic and - * routes it through registered reporters; prefix with `throw` to raise. - */ - readonly logger: Record +--- +outline: deep +--- - /** Register additional diagnostic definitions. */ - register: (definitions: Record) => void +# Structured Diagnostics - /** - * Mirror of `nostics`'s `defineDiagnostics`, pre-wired with the host's - * ANSI console reporter — plugins typically omit `reporters`. - */ - defineDiagnostics: typeof defineDiagnostics -} -``` +`ctx.diagnostics` lets a plugin register coded errors and warnings — each with a stable code, a docs URL, and a structured payload — into a shared registry. It's a thin layer over [`nostics`](https://github.com/vercel-labs/nostics), so plugins get coded diagnostics without depending on `nostics` directly. -The host ships pre-seeded with devframe's own `DF*` codes plus the Vite-specific `DTK*` codes registered by `@vitejs/devtools`. Call `register()` to fold your own definitions in. +Use it for author-defined codes (`MYP0001: Plugin not configured`). For free-form runtime output shown in the UI, use [Messages](./messages). The mechanics are documented in the [Devframe diagnostics guide](https://devfra.me/guide/diagnostics). ## Register your own codes @@ -55,91 +32,19 @@ export function MyPlugin(): PluginWithDevTools { }) ctx.diagnostics.register(diagnostics) - - // Emit codes through the shared lookup: - ctx.diagnostics.logger.MYP0002() }, }, } } ``` -## Code prefix conventions - -Codes are 4-letter prefix + 4-digit number (e.g. `MYP0001`). Pick a prefix specific to your plugin — short enough to type, distinctive enough to avoid collisions with other integrations. - -Prefixes used by the in-tree packages: - -| Prefix | Owner | -|--------|-------| -| `DF` | `devframe` | -| `DTK` | `@vitejs/devtools` | -| `RDDT` | `@vitejs/devtools-rolldown` | -| `VDT` | `@vitejs/devtools-vite` | - -Each definition supports `why` (string or function returning a string) and an optional `fix` (string or function). A `docsBase` on the definition group auto-attaches a per-code URL to every emitted diagnostic. - -## Emit a diagnostic - -Each registered code is reachable as a property on `ctx.diagnostics.logger`. Every handle is a callable — invoke it to report (returns the `Diagnostic`), or prefix with `throw` to raise. - -```ts -// Throw — control flow stops here -throw ctx.diagnostics.logger.MYP0001({ name: 'foo' }) - -// Report without throwing (default console method: `warn`) -ctx.diagnostics.logger.MYP0002() - -// Override the console method per call -ctx.diagnostics.logger.MYP0002({}, { method: 'error' }) - -// Attach a `cause` via the params object -ctx.diagnostics.logger.MYP0001({ name: 'foo', cause: error }) -``` - -The callable returns a `Diagnostic` (which extends `Error`). Prefix with `throw` so TypeScript narrows the lines after as unreachable: - -```ts -throw ctx.diagnostics.logger.MYP0001({ name }) -``` - -## Typed handle reference +## Emit -`ctx.diagnostics.logger` is a loosely typed proxy — it covers an unbounded set of registered codes, beyond what TypeScript can narrow. For autocompletion on your plugin's specific codes, keep a reference to the typed handle returned by `defineDiagnostics()`: +Each registered code is a callable on `ctx.diagnostics.logger`. Prefix with `throw` to raise it, or call it to report (defaults to `console.warn`): ```ts -const myDiagnostics = ctx.diagnostics.defineDiagnostics({ - docsBase: 'https://example.com/errors', - codes: { - MYP0001: { why: (p: { name: string }) => `…${p.name}` }, - }, -}) - -// Register so the shared lookup can also see it -ctx.diagnostics.register(myDiagnostics) - -// Use the typed handle directly at emit sites -myDiagnostics.MYP0001({ name: 'foo' }) +ctx.diagnostics.logger.MYP0002() // reported +throw ctx.diagnostics.logger.MYP0001({ name: 'foo' }) // thrown ``` -Both paths share the formatter and reporter defaults set by the host (ANSI console output). - -## Document your codes - -Pair each code with a documentation page so emitted diagnostics carry a clickable URL back to a fix: - -``` -docs/errors/ - index.md # Table of all codes - MYP0001.md # One page per code - MYP0002.md -``` - -Each page covers the message, cause, example, and fix; see any [DTK code page](/errors/) for the canonical template. Setting `docsBase` on `defineDiagnostics({...})` auto-attaches the URL to every emitted diagnostic. - -## When to use what - -- **`ctx.diagnostics`** — coded conditions worth looking up: misconfiguration, deprecations, validation failures, internal invariants. Always docs-backed. Often `throw`-prefixed. -- **[`ctx.messages`](./messages)** — user-facing activity surfaces in the DevTools UI: progress indicators, audit results, "URL copied" toasts. Just a message and a level. - -Diagnostics target tool authors and CI; messages target the human in front of the DevTools panel. +The registry ships pre-seeded with devframe's `DF*` codes and the Vite-specific `DTK*` codes. Vite DevTools' own codes are catalogued in the [Error Reference](/errors/). diff --git a/docs/kit/dock-system.md b/docs/kit/dock-system.md index e742c5d5c..97c37e4ea 100644 --- a/docs/kit/dock-system.md +++ b/docs/kit/dock-system.md @@ -4,51 +4,31 @@ outline: deep # Dock System -Dock entries are how users open your DevTools integration — clickable items in the dock, similar to the macOS Dock. +Dock entries are how users open your integration — clickable items in the DevTools dock, similar to the macOS Dock. You register them from `devtools.setup` via `ctx.docks`. -## Entry types +The dock is a hub concept shared across every host framework; its full entry-type reference, when-clauses, and remote-dock support live in the [Devframe hub docs](https://devfra.me/guide/hub). This page covers the parts a Vite integration reaches for most. -Kit supports six dock entry types: +## Entry types -| Type | Description | Use Case | -|------|-------------|----------| -| `iframe` | Displays your UI in an iframe panel | Full-featured UIs, dashboards, data visualization | -| `action` | Button that triggers client-side scripts | Inspectors, toggles, one-time actions | -| `custom-render` | Renders directly in the user's app DOM | When you need direct DOM access or framework integration | -| `launcher` | Actionable setup card shown in panel | Run one-time setup tasks before showing other tools | -| `json-render` | Renders UI from a JSON spec — no client code needed | Data panels, config viewers, simple interactive tools | -| `group` | Collapses related entries under one dock button | Bundling a framework's tools under a single button | +| Type | Description | +|------|-------------| +| `iframe` | Your UI in an isolated iframe panel — the default for full UIs | +| `action` | A button that runs a client-side script | +| `custom-render` | Renders directly in the user's app DOM | +| `launcher` | A setup card that runs a one-time task before showing a tool | +| `json-render` | UI from a serializable spec — no client bundle needed | +| `group` | Collapses related entries under one dock button | ## Iframe panels -The default choice — host your UI in an iframe. The frame stays isolated from the user's app and works with any framework. - -### Basic example - -```ts -ctx.docks.register({ - id: 'my-plugin', - title: 'My Plugin', - icon: 'https://example.com/logo.svg', - type: 'iframe', - url: 'https://example.com/devtools', -}) -``` - -### Hosting your own UI - -For most use cases, you build and host your own UI. DevTools serves the static files: +The common case: host a built SPA with `ctx.views.hostStatic()` and point an iframe entry at the same route. ```ts import { fileURLToPath } from 'node:url' -// Path to your built SPA const clientDist = fileURLToPath(new URL('../dist/client', import.meta.url)) - -// Host the static files ctx.views.hostStatic('/__my-plugin/', clientDist) -// Register the dock entry ctx.docks.register({ id: 'my-plugin', title: 'My Plugin', @@ -58,549 +38,45 @@ ctx.docks.register({ }) ``` -DevTools serves the files via dev-server middleware and copies them into the build output for production. - -### Dock entry options - -```ts -interface DockEntry { - /** Unique identifier for this entry */ - id: string - /** Display title shown in the dock */ - title: string - /** Icon URL, data URI, or Iconify icon name (e.g., 'ph:house-duotone') */ - icon: string | { light: string, dark: string } - /** Entry type */ - type: 'iframe' | 'action' | 'custom-render' | 'launcher' | 'json-render' | 'group' - /** Id of the group this entry belongs to — see Docked groups */ - groupId?: string - /** Member opened when a group button is activated (for type: 'group') */ - defaultChildId?: string - /** Per-group override of in-group sub-category order (for type: 'group') — see Categories inside a group */ - categoryOrder?: Record - /** URL to load in the iframe (for type: 'iframe') */ - url?: string - /** Action configuration (for type: 'action') */ - action?: { importFrom: string, importName: string } - /** Renderer configuration (for type: 'custom-render') */ - renderer?: { importFrom: string, importName: string } - /** Launcher configuration (for type: 'launcher') */ - launcher?: { - title: string - onLaunch: () => Promise - description?: string - buttonStart?: string - buttonLoading?: string - /** Bound command id — the launch button, palette, and keybinding share it */ - command?: string - /** Terminal session this launcher tracks (enables "View in Terminal") */ - terminalSessionId?: string - /** Author-set single line of progress/status, shown inline on the card */ - digest?: string - } - /** JsonRenderer handle created by ctx.createJsonRenderer() (for type: 'json-render') */ - ui?: JsonRenderer -} -``` - -### Icons - -Icons accept a URL, a data URI, or an [Iconify](https://icon-sets.iconify.design/) name. The `ph:` (Phosphor) set pairs well with DevTools UIs. - -```ts -// URL to an image -icon: 'https://example.com/logo.svg' - -// Data URI -icon: 'data:image/svg+xml,...' - -// Iconify icon name -icon: 'ph:chart-bar-duotone' // Phosphor Icons -icon: 'carbon:analytics' // Carbon Icons -icon: 'mdi:view-dashboard' // Material Design Icons - -// Light/dark variants -icon: { - light: 'https://example.com/logo-light.svg' - dark: 'https://example.com/logo-dark.svg' -} -``` - -The [File Explorer example](/kit/examples#file-explorer) is a complete iframe-dock plugin with RPC and static-build support. - -### Remote-hosted UIs - -To skip bundling 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). - -### Shared-iframe soft navigation - -A multi-tab integration — say a devtool with its own Modules / Timeline / Plugins views inside one SPA — can surface each of its tabs as its own DevTools dock while all of them share **one** live iframe and switch views by client-side (soft) navigation, with no reload. - -Flag the iframe dock as an **anchor** with `subTabs` and give it a `frameId`: - -```ts -ctx.docks.register({ - id: 'nuxt-devtools', - type: 'iframe', - title: 'Nuxt DevTools', - icon: 'i-logos:nuxt-icon', - url: 'http://localhost:3000/__nuxt_devtools__/', - frameId: 'nuxt-devtools', // the shared iframe these docks render into - subTabs: { protocol: 'postmessage' }, // opt into the frame-nav adapter -}) -``` - -When the anchor's iframe mounts, Vite DevTools attaches the hub's frame-nav adapter. It runs a versioned, origin-locked `postMessage` handshake with the embedded app, turns the tab manifest the app reports into one **member dock** per tab (id `:`), and drives the loop both ways: selecting a member soft-navigates the shared frame, and the app's own navigation moves the DevTools highlight to match. Members are first-class docks — they honor `title`, `icon`, `order`, `category`, `when`, `badge`, and grouping (`frameId` and `groupId` are independent axes). - -The embedded app stays decoupled: it ships a small `postMessage` nav shim and takes no hub or RPC dependency, so this works cross-origin and in static builds. When no shim answers within the handshake window, the anchor renders as a single plain iframe dock. The protocol, the member-dock data model, and the shim contract live in devframe's [shared-iframe soft-navigation design](https://github.com/devframes/devframe/blob/main/plans/shared-iframe-soft-nav.md). - -Set [`visibility: 'false'`](/kit/when-clauses#render-only-visibility) on the anchor when only its synthesized member tabs should have their own dock-bar buttons — the anchor keeps driving the nav loop, but its own button disappears. - -## Action buttons - -Action buttons run a client-side script when clicked. They suit: - -- Temporary inspector tools (DOM inspector, component picker). -- Feature toggles. -- One-shot actions where a button is enough. - -### Registration - -```ts -ctx.docks.register({ - id: 'my-inspector', - title: 'Inspector', - icon: 'ph:cursor-duotone', - type: 'action', - action: { - importFrom: 'my-plugin/devtools-action', - importName: 'default', - }, -}) -``` - -### Client script - -The action script runs in the user's browser. It receives the [client context](/kit/client-context), extended with the dock-scoped `current` (entry state and events) and `messages`: - -```ts -// src/devtools-action.ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default function setupAction(ctx: DockClientScriptContext) { - let isActive = false - let overlay: HTMLElement | null = null - - ctx.current.events.on('entry:activated', () => { - isActive = true - console.log('Inspector activated') - - // Create an overlay - overlay = document.createElement('div') - overlay.style.cssText = ` - position: fixed; - inset: 0; - cursor: crosshair; - z-index: 99999; - ` - - overlay.addEventListener('click', (e) => { - const target = document.elementFromPoint(e.clientX, e.clientY) - console.log('Selected element:', target) - }) - - document.body.appendChild(overlay) - }) - - ctx.current.events.on('entry:deactivated', () => { - isActive = false - console.log('Inspector deactivated') - - // Cleanup - overlay?.remove() - overlay = null - }) -} -``` - -### Package export - -Export the action script from your package: - -```json -{ - "name": "my-plugin", - "exports": { - ".": "./dist/index.mjs", - "./devtools-action": "./dist/devtools-action.mjs" - } -} -``` - -### Available events - -| Event | Description | -|-------|-------------| -| `entry:activated` | Fires when the user activates this dock entry | -| `entry:deactivated` | Fires when another entry is selected or the dock is closed | - -For a real-world action dock, see the [A11y Checker example](/kit/examples#a11y-checker) — it runs axe-core audits and reports violations as logs. - -## Custom renderers - -Custom renderers paint directly into the DevTools panel DOM. Use them when you want direct DOM access, want to mount a framework app into the panel, or want to skip iframe isolation. - -### Registration - -```ts -ctx.docks.register({ - id: 'my-custom-view', - title: 'Custom View', - icon: 'ph:code-duotone', - type: 'custom-render', - renderer: { - importFrom: 'my-plugin/devtools-renderer', - importName: 'default', - }, -}) -``` - -### Renderer script - -```ts -// src/devtools-renderer.ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default function setupRenderer(ctx: DockClientScriptContext) { - ctx.current.events.on('dom:panel:mounted', (panel) => { - // `panel` is a DOM element you can render into - - // Option 1: Vanilla JS - panel.innerHTML = ` -
-

My Custom View

- -
- ` - panel.querySelector('#my-btn')?.addEventListener('click', () => { - console.log('Button clicked!') - }) - - // Option 2: Mount a Vue app - // import { createApp } from 'vue' - // import App from './App.vue' - // createApp(App).mount(panel) - - // Option 3: Mount a React app - // import { createRoot } from 'react-dom/client' - // import App from './App' - // createRoot(panel).render() - }) - - ctx.current.events.on('entry:deactivated', () => { - // Optional cleanup - }) -} -``` - -### Available events - -| Event | Payload | Description | -|-------|---------|-------------| -| `dom:panel:mounted` | `HTMLElement` | Panel DOM is ready for rendering | -| `entry:activated` | — | Entry was activated | -| `entry:deactivated` | — | Entry was deactivated | - -The panel DOM is preserved across dock-entry switches, so your UI persists and the one-time setup belongs in `dom:panel:mounted`. - -## Launcher entries - -Launchers render a dedicated setup panel and run a server-side launch task. They suit integrations that need an explicit initialization step — starting a terminal task, generating artifacts, and so on. - -```ts -ctx.docks.register({ - id: 'my-launcher', - title: 'My Setup', - icon: 'ph:rocket-launch-duotone', - type: 'launcher', - launcher: { - title: 'Initialize Integration', - description: 'Run initial setup before opening tools', - onLaunch: async () => { - // perform setup work here - }, - }, -}) -``` - -### Binding a command - -Point `launcher.command` at a registered command so the launch button, the command palette, and any keybinding all run one handler. `command` is the serializable launch path, so `onLaunch` is optional: - -```ts -const COMMAND_ID = 'my-plugin:start' -ctx.commands.register({ id: COMMAND_ID, title: 'Start My App', handler: start }) - -ctx.docks.register({ - id: 'my-launcher', - title: 'My App', - icon: 'ph:rocket-launch-duotone', - type: 'launcher', - launcher: { - title: 'Start My App', - command: COMMAND_ID, - }, -}) -``` - -### Tracking a terminal session - -When the launch spawns a process, tie the launcher to its terminal session. Set `terminalSessionId` to surface a **View in Terminal** action (which opens the Terminals dock focused on that session), and set `digest` to a short status of the process — its progress, not its raw output: - -```ts -const session = await ctx.terminals.startChildProcess( - { command: 'vite', args: ['dev'], cwd }, - { id: 'my-app:dev', title: 'Dev Server' }, -) - -ctx.docks.update({ - id: 'my-launcher', - title: 'My App', - icon: 'ph:rocket-launch-duotone', - type: 'launcher', - launcher: { - title: 'Start My App', - status: 'loading', - terminalSessionId: 'my-app:dev', - digest: 'Waiting for the server…', - }, -}) -``` - -The **View in Terminal** action calls the hub's `hub:docks:activate` RPC (devframe 0.7.3+), which switches the host shell to the Terminals dock and focuses the tracked session — where the full output lives. - -`createProcessLauncher` composes all of the above (register + command binding + `prepare` + spawn + digest + session navigation) in one call. A plain **terminal launcher** stays a launcher while a long-running process runs: - -```ts -import { createProcessLauncher } from '@vitejs/devtools-kit/node' - -createProcessLauncher({ - id: 'my-app', - title: 'My App', - icon: 'ph:rocket-launch-duotone', - process: { command: 'vite', args: ['dev'], cwd: process.cwd() }, -}) -``` - -Pass `serve.onReady` for the common **server launcher** shape — run some commands, start a server, then replace the card with an iframe embedding it. The card shows a status while `onReady` resolves the URL, then the dock swaps to the iframe: - -```ts -let url: string - -createProcessLauncher({ - id: 'my-ui', - title: 'My UI', - icon: 'ph:browser-duotone', - // Optional: run setup (e.g. install an optional dep) before spawning. - prepare: async () => { - /* install-on-demand */ - }, - process: async () => { - const port = await getPort() - url = `http://localhost:${port}/` - return { command: 'my-ui', args: ['--port', String(port)], cwd: process.cwd() } - }, - serve: { - onReady: async () => { - await waitForServer(url) - return url - }, - }, -}) -``` - -The launcher tracks the spawned process for the life of the embed. When that process exits — you stop it, it crashes, or it ends on its own — the dock swaps the iframe back to an idle launcher so the embedded UI never points at a dead server. Relaunching clears the previous run's terminal session before spawning a fresh one, so the session id stays collision-free across restarts. - -## JSON render panels - -JSON render panels describe a UI as a JSON spec on the server — the client renders it from a built-in component library. This is the shortest path to a DevTools panel: server-side TypeScript only. - -Create a renderer handle with `ctx.createJsonRenderer()` and pass it as `ui` when registering a `json-render` dock entry: - -```ts -const ui = ctx.createJsonRenderer({ - root: 'root', - elements: { - root: { - type: 'Stack', - props: { direction: 'column', gap: 12 }, - children: ['heading', 'info'], - }, - heading: { - type: 'Text', - props: { text: 'Hello from JSON!', variant: 'heading' }, - }, - info: { - type: 'KeyValueTable', - props: { - data: { - Version: '1.0.0', - Status: 'Running', - }, - }, - }, - }, -}) - -ctx.docks.register({ - id: 'my-panel', - title: 'My Panel', - icon: 'ph:chart-bar-duotone', - type: 'json-render', - ui, -}) -``` - -See [JSON Render](/kit/json-render) for the full component reference, dynamic updates, actions, state bindings, and examples. - -## Docked groups - -Collapse several related entries under one dock button. A group shows as a single button on the dock bar; activating it reveals its members in a popover, and opening a member shows that view alongside a thin sidebar for switching between siblings. This lets a framework split its features into separate, individually-pluggable entries while presenting them as one unit. - -Register a `group` entry, then point each member at it with `groupId`: - -```ts -ctx.docks.register({ - id: 'nuxt', - title: 'Nuxt', - icon: 'logos:nuxt-icon', - type: 'group', - defaultChildId: 'nuxt:overview', -}) - -ctx.docks.register({ - id: 'nuxt:overview', - title: 'Overview', - icon: 'ph:gauge-duotone', - type: 'iframe', - url: '/__nuxt-overview/', - groupId: 'nuxt', -}) -``` - -A group carries the usual `title`/`icon`/`category`/`defaultOrder`/`when` fields and has no view of its own. `defaultChildId` names the member opened when the group button is activated; without it, the button reveals the member popover and opens a view once a member is chosen. - -Pointing `defaultChildId` at a [shared-iframe anchor](#shared-iframe-soft-navigation) that is hidden with `visibility: 'false'` is the idiomatic way to boot a soft-nav frame: activating the group mounts the anchor's iframe the first time, and every later activation resurfaces the frame's current member tab so a visible dock stays highlighted rather than the anchor itself. - -Membership is a flat pointer, not containment: every member stays an independently-registered top-level entry. A member whose `groupId` references a group that was never registered renders as a normal top-level entry, and a group with no members stays hidden until an entry joins it. Grouping is one level deep — a group entry does not set its own `groupId`. - -### Categories inside a group - -The `category` field plays a dual role. On a top-level entry it is the outer dock-bar bucket. On a **grouped** member — one whose `groupId` resolves to a registered group — the outer bucket is the **group's** own `category`, and the member's `category` becomes an **in-group sub-category** that divides the group's popover, edge-mode sidebar, settings list, and command-palette drill-down into sections. Sub-categories order by the same category table as the outer bar and default to `default` when unset. - -```ts -// The group's category ('framework') is the outer bucket for the whole group. -ctx.docks.register({ id: 'nuxt', title: 'Nuxt', icon: 'logos:nuxt-icon', type: 'group', category: 'framework' }) - -// Members sort into 'app' and 'advanced' SUB-categories inside the Nuxt group, -// while the group button itself lives in 'framework' on the bar. -ctx.docks.register({ id: 'nuxt:overview', title: 'Overview', icon: 'ph:gauge-duotone', type: 'iframe', url: '/__nuxt/overview/', groupId: 'nuxt', category: 'app' }) -ctx.docks.register({ id: 'nuxt:graph', title: 'Graph', icon: 'ph:graph-duotone', type: 'iframe', url: '/__nuxt/graph/', groupId: 'nuxt', category: 'advanced' }) -``` - -An orphan member (its `groupId` matches no registered group) has no group to supply an outer bucket, so it falls back to its own `category`. - -A group can reshuffle its own sub-category order with `categoryOrder`, a `Record` that overrides `DEFAULT_CATEGORIES_ORDER` for that group's members only — every other group and the outer dock-bar order are untouched: - -```ts -// 'advanced' now leads 'app' inside this group, reversing the shared default. -ctx.docks.register({ - id: 'nuxt', - title: 'Nuxt', - icon: 'logos:nuxt-icon', - type: 'group', - category: 'framework', - categoryOrder: { advanced: -1, app: 1 }, -}) -``` - -A sub-category the map omits keeps its weight from the shared table. - -### The built-in Vite+ group - -Vite DevTools seeds a built-in **Vite+** group that collects Vite ecosystem integrations under one button. Join it with the exported id: - -```ts -import { DEVTOOLS_VITEPLUS_GROUP_ID } from '@vitejs/devtools-kit/constants' - -ctx.docks.register({ - id: 'rolldown', - title: 'Rolldown', - icon: 'https://example.com/rolldown.svg', - type: 'iframe', - url: '/__devtools-rolldown/', - groupId: DEVTOOLS_VITEPLUS_GROUP_ID, -}) -``` - -DevTools for Rolldown joins this group out of the box. +Icons accept a URL, a data URI, or an [Iconify](https://icon-sets.iconify.design/) name (the `ph:` Phosphor set pairs well with DevTools UIs), or `{ light, dark }` for theme-aware icons. -### Visibility and order +## Groups -From the dock settings panel, users hide or reorder members within a group independently, and hide the whole group from its row. When a group's members span several sub-categories, each sub-category reorders on its own and shows its own header. +Set a shared `groupId` to collapse several entries under one dock button. Vite DevTools ships the `viteplus` group, which bundles the optional first-party integrations (Rolldown, Vite, Vitest, Oxc) behind a single button — pass `groupId: 'viteplus'` to place an entry there. -Pinning an entry moves it into a dedicated **Pinned** category that leads the dock bar ahead of every other category. A top-level entry (or a whole group button) pins to the bar-level Pinned bucket; a grouped member pins to a Pinned sub-category that leads its own group, staying inside the group rather than surfacing on the bar. A pinned entry shows even when its home category is hidden, and unpinning returns it to that category in its previous position. +## Install launchers -## Common options - -Every dock type accepts these base fields: - -| Field | Type | Description | -|-------|------|-------------| -| `id` | `string` | Unique, namespaced. | -| `title` | `string` | Label shown in the dock. | -| `icon` | `string \| { light, dark }` | Iconify name, URL, data URI, or light/dark pair. | -| `category` | `'app' \| 'framework' \| 'web' \| 'advanced' \| 'default'` | Outer dock-bar bucket, or the in-group sub-category when `groupId` resolves to a group — see [Categories inside a group](#categories-inside-a-group). Defaults to `'default'`. | -| `defaultOrder` | `number` | Orders entries within a category; lower numbers appear first. Default `0`. | -| `when` | `string` | Visibility expression — see [When Clauses](/kit/when-clauses). | -| `visibility` | `string` | Render-only counterpart to `when` — hides just this entry's dock-bar button, leaving it registered and reachable. See [Render-only visibility](/kit/when-clauses#render-only-visibility). | -| `badge` | `string` | Short text badge (e.g. unread count). | -| `groupId` | `string` | Collapse this entry under a group's button; the group's `category` becomes this entry's outer bucket — see [Docked groups](#docked-groups). | - -## Update - -`register()` returns a handle with an `update(patch)` method: - -```ts -const handle = ctx.docks.register({ /* ... */ }) - -// Live update (e.g. refresh the badge) -handle.update({ badge: '3' }) -``` - -## Communication with the server - -Action scripts and custom renderers talk to the server through [RPC](./rpc): +Vite DevTools stays dependency-light: an optional integration that isn't installed yet appears as a `launcher` entry. Clicking it installs the package on demand (as a tracked terminal session), then prompts a dev-server restart so the real plugin can mount. `createInstallLauncher` builds that plugin for you. ```ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' +import { createInstallLauncher } from '@vitejs/devtools-kit/node' -export default function setup(ctx: DockClientScriptContext) { - ctx.current.events.on('entry:activated', async () => { - // Call a server function - const data = await ctx.rpc.call('my-plugin:get-data') - console.log('Data from server:', data) - }) +export default { + plugins: [ + createInstallLauncher({ + id: 'my-integration', + title: 'My Integration', + icon: 'ph:sparkle-duotone', + groupId: 'viteplus', + install: ['@acme/vite-devtools-my-integration@^1.0.0'], + }), + ], } ``` -Or use `getDevToolsRpcClient()` in iframe pages: +| Option | Description | +|--------|-------------| +| `id` | Dock entry id — usually the same id the real integration registers, so launcher and mounted dock share a rail slot | +| `title` | Dock title / rail tooltip | +| `icon` | A served URL or an Iconify `collection:name` | +| `install` | npm specs to ensure are installed; only missing ones are installed, in one call | +| `groupId` | Dock group, e.g. `'viteplus'` | +| `label` | Friendly name used in the launcher copy (defaults to `title`) | +| `pkg` | Package named in the button copy (defaults to the first `install` spec's bare name) | +| `dev` | Install as devDependencies (default `true`) | -```ts -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' +The launcher installs at the workspace root — these are devtools for the whole workspace — and, on failure, surfaces [`DTK0050`](/errors/DTK0050) with the terminal output linked from the card. -const rpc = await getDevToolsRpcClient() -const data = await rpc.call('my-plugin:get-data') -``` +## Talking to the server -See [RPC](./rpc) for complete documentation on server-client communication. +Action scripts and iframe UIs communicate with the Node side over [RPC](./rpc), and share reactive data through [shared state](./shared-state). The `action`, `custom-render`, and `json-render` entry types, along with visibility `when` clauses, are documented in the [Devframe hub docs](https://devfra.me/guide/hub). diff --git a/docs/kit/examples.md b/docs/kit/examples.md index abac95140..35d40fd1e 100644 --- a/docs/kit/examples.md +++ b/docs/kit/examples.md @@ -10,52 +10,24 @@ Reference plugins built with `@vitejs/devtools-kit`, each focused on a different ### A11y Checker -Accessibility auditing powered by [axe-core](https://github.com/dequelabs/axe-core). - -**Features demonstrated:** - -- An `action` dock entry with a client-side script. -- Running axe-core audits on the current page. -- Reporting violations as DevTools logs with severity levels. -- Using log handles to update a summary log in place. +Accessibility auditing powered by [axe-core](https://github.com/dequelabs/axe-core): an `action` dock entry with a client-side script that runs audits on the current page and reports violations as DevTools messages, updating a summary in place via a handle. **Source:** [`examples/plugin-a11y-checker`](https://github.com/vitejs/devtools/tree/main/examples/plugin-a11y-checker) ### File Explorer -A file-explorer dock that lists, reads, and writes files through RPC. - -**Features demonstrated:** - -- `static`, `query`, and `action` RPC functions. -- Custom UI panel hosted with `context.views.hostStatic(...)`. -- An `iframe` dock entry. -- RPC dump support for static builds. -- Backend-mode detection (`websocket` vs `static`) on the client. +A file-explorer dock that lists, reads, and writes files through RPC — `static`, `query`, and `action` functions behind an `iframe` dock, with a custom UI hosted via `ctx.views.hostStatic()` and backend-mode detection on the client. **Source:** [`examples/plugin-file-explorer`](https://github.com/vitejs/devtools/tree/main/examples/plugin-file-explorer) ### Git UI -An interactive Git panel built from server-side JSON specs — no client code. - -**Features demonstrated:** - -- The `json-render` dock type for zero-client-code panels. -- Building a `JsonRenderSpec` from server-side data (branch, status, log). -- Dynamic spec updates via shared state (`sharedStateKey`). -- Button actions bridged to RPC functions (`git-ui:refresh`, `git-ui:commit`). -- Text input with `$bindState` two-way binding and `$state` in action params. -- Reactively updating the dock badge. +An interactive Git panel built entirely from server-side [json-render](https://devfra.me/guide/json-render) specs — no client bundle. Shows dynamic spec updates via shared state, button actions bridged to RPC, and two-way text input binding. **Source:** [`examples/plugin-git-ui`](https://github.com/vitejs/devtools/tree/main/examples/plugin-git-ui) -## Real-world examples - -Existing DevTools integrations worth reading: - -- **[UnoCSS Inspector](https://github.com/unocss/unocss/blob/25c0dd737132dc20b257c276ee2bc3ccc05e2974/packages-integrations/inspector/src/index.ts#L140-L150)** — a small iframe dock entry. -- **[vite-plugin-vue-tracer](https://github.com/antfu/vite-plugin-vue-tracer)** — an action button that triggers a DOM inspector. See the [plugin hook](https://github.com/antfu/vite-plugin-vue-tracer/blob/9f86fe723543405eea5d30588fe783796193bfd8/src/plugin.ts#L139-L157) and the [client script](https://github.com/antfu/vite-plugin-vue-tracer/blob/main/src/client/vite-devtools.ts). -- **[`@vitejs/devtools-oxc`](https://github.com/vitejs/devtools/blob/main/packages/oxc/src/vite.ts)** — the first-party Oxc inspector: an iframe dock entry with custom RPC functions. +## Real-world integrations -PRs to improve coverage are welcome. +- **[UnoCSS Inspector](https://github.com/unocss/unocss)** — a small iframe dock entry. +- **[vite-plugin-vue-tracer](https://github.com/antfu/vite-plugin-vue-tracer)** — an action button that triggers a DOM inspector. +- **[`@vitejs/devtools-oxc`](https://github.com/vitejs/devtools/blob/main/packages/oxc/src/vite.ts)** — the first-party Oxc inspector: an iframe dock with custom RPC functions. diff --git a/docs/kit/index.md b/docs/kit/index.md index c9b308adc..380da6111 100644 --- a/docs/kit/index.md +++ b/docs/kit/index.md @@ -4,62 +4,17 @@ outline: deep # DevTools Kit -DevTools Kit is the integration hub for Vite DevTools. It owns the dock, the command palette, terminal aggregation, cross-tool toasts, and the `Plugin.devtools.setup` hook that any Vite plugin can implement to surface a UI inside DevTools. +`@vitejs/devtools-kit` is the Vite-flavored layer for building integrations that appear inside Vite DevTools. It gives a Vite plugin one extra hook — `devtools.setup(ctx)` — and a context wired with everything an integration needs: a dock to surface UI, RPC, shared state, terminals, commands, and messages. -For a fresh Vite-specific integration, reach for `Plugin.devtools.setup` directly — that's where docks, terminals, the palette, and custom renderers live. Kit is built on [Devframe](https://devfra.me/guide/), the framework-neutral foundation; tools that already have a portable Devframe definition drop into the hub via `createPluginFromDevframe`, and standalone single-tool deployments can build on Devframe directly. +Kit is a thin skin over [Devframe](https://devfra.me/guide/) and its hub layer. Devframe is the framework-neutral foundation that owns the mechanics — [RPC](https://devfra.me/guide/rpc), [shared state](https://devfra.me/guide/shared-state), [json-render](https://devfra.me/guide/json-render), [streaming](https://devfra.me/guide/streaming), [when clauses](https://devfra.me/references/when-clauses), and the hub's [docks, commands, messages, and terminals](https://devfra.me/guide/hub). Kit re-exports those under Vite-friendly names and adds the Vite-specific pieces: the `devtools.setup` hook, the `viteConfig` / `viteServer` context slots, the `/__devtools/` mount path, and the bridge that drops a portable devframe into Vite DevTools. ![DevTools Kit Vision](/assets/vision-devtools-kit.jpg) For background, see [Anthony Fu's ViteConf 2025 talk](https://www.youtube.com/watch?v=tVd0JeSr8kg). -## What DevTools Kit provides - -Kit owns the hub-level surface — the things that only matter once multiple integrations share a UI: - -| Feature | Description | -|---------|-------------| -| **[DevTools Plugin](./devtools-plugin)** | The `Plugin.devtools.setup` hook, plus `createPluginFromDevframe` for porting Devframe apps into the hub. | -| **[Dock System](./dock-system)** | The unified dock — iframe / action / custom / launcher / json-render entries — with categories, when-clauses, and remote dock support. | -| **[Commands](./commands)** | The shared command palette: keybindings, children, when-gating across every integration. | -| **[Messages](./messages)** | Cross-tool toast notifications and the unified messages dock. | -| **[Terminals](./terminals)** | Aggregate terminal output from any integration into one Terminals panel. | -| **[RPC](./rpc)** | Type-safe bidirectional RPC backed by Devframe's birpc + valibot. | -| **[Shared State](./shared-state)** | Patch-synced state that bridges server ↔ client across every integration. | -| **Isomorphic Views** | Deploy your UI as embedded panels, browser extensions, or standalone webpages. | - -## Architecture Overview - -```mermaid -flowchart TB - subgraph Browser["Browser (Client)"] - direction TB - subgraph DockEntries["Dock Entries"] - Iframe["Iframe Panel"] - Action["Action Button"] - Custom["Custom Renderer"] - end - RpcClient["RPC Client"] - DockEntries --> RpcClient - end - - RpcClient <-->|WebSocket| RpcServer - - subgraph Server["Node.js (Server)"] - direction TB - RpcServer["RPC Server"] - subgraph Context["DevTools Node Context"] - Docks["Docks Host"] - Views["Views Host"] - Rpc["RPC Host"] - State["State Host"] - end - RpcServer --> Context - end -``` - -## Quick example +## Two ways to build -Authoring a Vite plugin? Add a `devtools.setup` hook and register a dock entry: +**Author a Vite plugin.** Add a `devtools.setup` hook to any Vite plugin and register a dock entry. This is the shortest path for a Vite-only integration. ```ts /// @@ -70,13 +25,12 @@ export default function myPlugin(): Plugin { name: 'my-plugin', devtools: { setup(ctx) { - // ctx is the kit-augmented context: rpc + docks + terminals + messages + commands ctx.docks.register({ id: 'my-plugin', title: 'My Plugin', - icon: 'https://example.com/icon.svg', + icon: 'ph:puzzle-piece-duotone', type: 'iframe', - url: 'https://example.com/devtools', + url: '/__my-plugin/', }) }, }, @@ -84,7 +38,7 @@ export default function myPlugin(): Plugin { } ``` -Already have a portable Devframe app? Wrap it once and Kit synthesises the iframe dock entry from the definition's `id` / `name` / `icon` / `basePath`: +**Bring a portable devframe.** If your tool is a [Devframe definition](https://devfra.me/guide/devframe-definition) — so it also runs standalone, as a CLI, or through a coding agent — wrap it once and Kit synthesizes the iframe dock entry for you. ```ts // vite.config.ts @@ -92,26 +46,32 @@ import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import devtool from './my-devtool' export default { - plugins: [ - createPluginFromDevframe(devtool, { - // Optional kit-only setup for hub features: - setup(ctx) { - ctx.commands.register({ - id: 'my-devtool:clear-cache', - title: 'Clear Cache', - handler: () => { /* ... */ }, - }) - }, - }), - ], + plugins: [createPluginFromDevframe(devtool)], } ``` -## Getting started +## Pages + +Getting an integration on screen: + +- **[DevTools Plugin](./devtools-plugin)** — the `devtools.setup` hook, the Vite-augmented context, and hosting a static UI. +- **[Dock System](./dock-system)** — registering dock entries and adding on-demand install launchers. +- **[Create Plugin from Devframe](./create-plugin-from-devframe)** — mount a portable devframe as a Vite plugin. + +Talking between server and client: + +- **[Client Script & Context](./client-context)** — the injected client script and the client-side context object. +- **[RPC](./rpc)** — type-safe, bidirectional calls between Node and the browser. +- **[Shared State](./shared-state)** — reactive state synced across every connected client. + +Composing with the hub: + +- **[Commands](./commands)** — the shared command palette. +- **[Messages & Notifications](./messages)** — structured entries and toasts in the Messages panel. +- **[Structured Diagnostics](./diagnostics)** — coded errors and warnings. +- **[Terminals & Processes](./terminals)** — spawn and stream child processes. +- **[Examples](./examples)** — reference plugins and real-world integrations. -1. **[DevTools Plugin](./devtools-plugin)** — register a hub plugin and walk the kit-augmented context. -2. **[Dock System](./dock-system)** — iframe panels, action buttons, custom renderers, launchers, json-render specs. -3. **[RPC](./rpc)** — bidirectional, type-safe communication between server and client. -4. **[Shared State](./shared-state)** — patch-synced state that bridges every integration. +These pages cover the Vite-plugin usage. For the deeper mechanics shared with every host framework — including [streaming](https://devfra.me/guide/streaming), [json-render](https://devfra.me/guide/json-render), and [when clauses](https://devfra.me/references/when-clauses) — see the [Devframe docs](https://devfra.me/guide/). If you're shipping something on Kit, tag the repo with `vite-devtools` on GitHub so we can see what folks are building. diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md deleted file mode 100644 index 7ed863db5..000000000 --- a/docs/kit/json-render.md +++ /dev/null @@ -1,720 +0,0 @@ ---- -outline: deep ---- - -# JSON Render - -JSON render panels build DevTools UIs from server-side TypeScript alone. You describe the UI as a JSON spec; the DevTools client renders it with the built-in component library. - -## Getting started - -Create a renderer handle with `ctx.createJsonRenderer()` and pass it as `ui` when registering a `json-render` dock entry: - -```ts -import type { PluginWithDevTools } from '@vitejs/devtools-kit' - -export function MyPlugin(): PluginWithDevTools { - return { - name: 'my-plugin', - devtools: { - setup(ctx) { - const ui = ctx.createJsonRenderer({ - root: 'root', - elements: { - root: { - type: 'Stack', - props: { direction: 'column', gap: 12 }, - children: ['heading', 'info'], - }, - heading: { - type: 'Text', - props: { text: 'Hello from JSON!', variant: 'heading' }, - }, - info: { - type: 'KeyValueTable', - props: { - data: { - Version: '1.0.0', - Status: 'Running', - }, - }, - }, - }, - }) - - ctx.docks.register({ - id: 'my-panel', - title: 'My Panel', - icon: 'ph:chart-bar-duotone', - type: 'json-render', - ui, - }) - }, - }, - } -} -``` - -## Spec structure - -A JSON render spec has three parts: a `root` element ID, an `elements` map, and an optional `state` object for two-way bindings. - -```ts -ctx.createJsonRenderer({ - root: 'root', - state: { - searchQuery: '', - }, - elements: { - root: { - type: 'Stack', - props: { direction: 'column', gap: 12 }, - children: ['title', 'content'], - }, - title: { - type: 'Text', - props: { text: 'My Panel', variant: 'heading' }, - }, - content: { - type: 'Text', - props: { text: 'Hello world' }, - }, - }, -}) -``` - -Every element has a `type` (component name), `props`, and optionally `children` (array of element IDs) or `on` (event handlers). - -## Dynamic updates - -The `JsonRenderer` handle returned by `ctx.createJsonRenderer()` exposes two methods for updating the UI reactively: - -```ts -const ui = ctx.createJsonRenderer(buildSpec(initialData)) - -// Replace the entire spec (e.g. after fetching new data) -await ui.updateSpec(buildSpec(newData)) - -// Shallow-merge into spec.state (updates client-side state values) -await ui.updateState({ searchQuery: 'vue' }) -``` - -You can also update the dock entry badge when data changes: - -```ts -ctx.docks.update({ - id: 'my-panel', - type: 'json-render', - title: 'My Panel', - icon: 'ph:chart-bar-duotone', - ui, - badge: hasWarnings ? '!' : undefined, -}) -``` - -## Handling actions via RPC - -Buttons in the spec can trigger RPC functions on the server. The `on` property carries an `action` key that matches a registered RPC function name: - -```ts -// In the spec — Button with an action -const ui = ctx.createJsonRenderer({ - root: 'refresh-btn', - elements: { - 'refresh-btn': { - type: 'Button', - props: { label: 'Refresh', icon: 'ph:arrows-clockwise' }, - on: { press: { action: 'my-plugin:refresh' } }, - }, - }, -}) -``` - -```ts -// On the server — register the matching RPC function: -ctx.rpc.register(defineRpcFunction({ - name: 'my-plugin:refresh', - type: 'action', - setup: ctx => ({ - handler: async () => { - const data = await fetchData() - await ui.updateSpec(buildSpec(data)) - }, - }), -})) -``` - -You can pass parameters from the spec to the action handler: - -```ts -const ui = ctx.createJsonRenderer({ - root: 'delete-btn', - elements: { - 'delete-btn': { - type: 'Button', - props: { label: 'Delete', variant: 'danger' }, - on: { - press: { - action: 'my-plugin:delete', - params: { id: 'some-id' }, - }, - }, - }, - }, -}) -``` - -## State and two-way binding - -`$bindState` on a TextInput `value` creates a two-way binding with a state key; `$state` reads the bound value in action params: - -```ts -const ui = ctx.createJsonRenderer({ - root: 'root', - state: { message: '' }, - elements: { - root: { - type: 'Stack', - props: { direction: 'row', gap: 8 }, - children: ['input', 'submit'], - }, - input: { - type: 'TextInput', - props: { - placeholder: 'Type here...', - value: { $bindState: '/message' }, - }, - }, - submit: { - type: 'Button', - props: { label: 'Submit', variant: 'primary' }, - on: { - press: { - action: 'my-plugin:submit', - params: { text: { $state: '/message' } }, - }, - }, - }, - }, -}) -``` - -The server-side handler receives the resolved state values: - -```ts -ctx.rpc.register(defineRpcFunction({ - name: 'my-plugin:submit', - type: 'action', - setup: ctx => ({ - handler: async (params: { text?: string }) => { - console.log('User submitted:', params.text) - }, - }), -})) -``` - -## Built-in components - -An element whose `type` doesn't match any of the components below — e.g. a spec authored against a newer base-catalog version than the connected client implements, or a plain typo — renders as a visible "Unsupported component" placeholder instead of disappearing silently, so a mismatch is easy to spot during development. - -### Layout - -#### Stack - -Flex layout container. Arranges children vertically or horizontally. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `direction` | `'row' \| 'column'` | `'column'` | Layout direction | -| `gap` | `number` | `8` | Gap between children in pixels | -| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | — | Cross-axis alignment | -| `justify` | `'start' \| 'center' \| 'end' \| 'between' \| 'around'` | — | Main-axis alignment | -| `wrap` | `boolean` | `false` | Allow children to wrap onto multiple lines | -| `flex` | `number \| string` | — | `flex` shorthand for the container | -| `padding` | `number` | — | Padding in pixels | -| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | Background tint (`danger`/`info`/`success`/`warning` also draw a matching border) | -| `interactive` | `boolean` | `false` | Tint the background on hover — useful for clickable-looking rows | - - -```ts -// Horizontal toolbar with items spaced apart -{ - type: 'Stack', - props: { direction: 'row', gap: 8, justify: 'between', align: 'center' }, - children: ['title', 'actions'], -} -``` - - -```ts -// Vertical form layout -{ - type: 'Stack', - props: { direction: 'column', gap: 12, padding: 16 }, - children: ['name-input', 'email-input', 'submit-btn'], -} -``` - -#### Card - -Container with an optional title and collapsible behavior. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `title` | `string` | — | Header title. A collapsible card still shows its (empty) header without one — otherwise there'd be nothing to click | -| `collapsible` | `boolean` | `false` | Whether the card can be collapsed | -| `defaultCollapsed` | `boolean` | `false` | Start collapsed (when `collapsible`) | -| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | A light background tint on the body, a stronger same-hue tint on the header bar, and a matching border (`danger`/`info`/`success`/`warning`) | -| `interactive` | `boolean` | `false` | Brighten the border on hover | -| `loading` | `boolean` | `false` | Show a loading state | - - -```ts -{ - type: 'Card', - props: { title: 'Build Info', collapsible: true }, - children: ['info-table'], -} -``` - -#### Tabs - -Switches which of its `children` is shown, one tab per entry in `tabs` (positionally matched — `children[i]` renders when `tabs[i]` is active). - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `tabs` | `Array<{ value: string, label: string, icon?: string, badge?: string, badgeVariant?: 'default' \| 'info' \| 'success' \| 'warning' \| 'danger' }>` | — | The tab list | -| `value` | `string` | — | Active tab's `value` (use `$bindState` for two-way binding) | -| `defaultValue` | `string` | first tab | Initial active tab when `value` isn't bound | -| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Underlined top bar vs. a left-hand rail | - -**Event**: `change` — fires when the active tab changes (including via arrow-key navigation). - - -```ts -{ - root: 'root', - elements: { - root: { - type: 'Tabs', - props: { - tabs: [ - { value: 'mfe', label: 'Micro-Frontends', badge: '3', badgeVariant: 'success' }, - { value: 'gateway', label: 'Gateway' }, - ], - }, - children: ['mfe-panel', 'gateway-panel'], - }, - 'mfe-panel': { type: 'Text', props: { text: '3 active overrides' } }, - 'gateway-panel': { type: 'Text', props: { text: 'No overrides' } }, - }, -} -``` - -#### Divider - -Visual separator line with an optional label. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `label` | `string` | — | Text shown in the middle of the divider | - - -```ts -{ - type: 'Divider', - props: { label: 'Advanced' }, -} -``` - -### Typography - -#### Text - -Display text with different visual styles. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `text` | `string` | — | Text content | -| `variant` | `'heading' \| 'subheading' \| 'body' \| 'caption' \| 'code'` | `'body'` | Visual style | -| `weight` | `'normal' \| 'medium' \| 'bold'` | — | Font weight | -| `color` | `'base' \| 'muted' \| 'faint' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | — | Text color | - - -```ts -// heading — 16px bold -{ type: 'Text', props: { text: 'Module Graph', variant: 'heading' } } - -// body (default) — 13px -{ type: 'Text', props: { text: 'Visualize module dependencies' } } - -// caption — 12px, muted -{ type: 'Text', props: { text: 'Click a node to inspect', variant: 'caption' } } - -// code — monospace with background -{ type: 'Text', props: { text: 'src/index.ts', variant: 'code' } } -``` - -#### Icon - -Renders an [Iconify](https://icon-sets.iconify.design/) icon by name. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `name` | `string` | — | Iconify icon name (e.g. `'ph:gear'`) | -| `size` | `number` | `20` | Icon size in pixels | - - -```ts -{ type: 'Icon', props: { name: 'ph:check-circle', size: 16 } } -``` - -#### Badge - -Status label with semantic color variants. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `text` | `string` | — | Badge text | -| `variant` | `'default' \| 'info' \| 'success' \| 'warning' \| 'danger'` | `'default'` | Color variant | -| `minWidth` | `number` | — | Minimum width in pixels | - - -```ts -{ type: 'Badge', props: { text: 'Ready', variant: 'success' } } -{ type: 'Badge', props: { text: '3 warnings', variant: 'warning' } } -{ type: 'Badge', props: { text: 'Failed', variant: 'danger' } } -``` - -### Inputs - -#### Button - -Clickable button that triggers an action via the `press` event. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `label` | `string` | — | Button text | -| `icon` | `string` | — | Iconify icon name | -| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger'` | `'secondary'` | Visual style | -| `disabled` | `boolean` | `false` | Disable interaction | -| `loading` | `boolean` | `false` | Show a loading state | - -**Event**: `press` — fires when the button is clicked. - - -```ts -// Label + icon -{ type: 'Button', props: { label: 'Refresh', icon: 'ph:arrows-clockwise' }, on: { press: { action: 'my-plugin:refresh' } } } - -// Danger variant -{ type: 'Button', props: { label: 'Clear Cache', variant: 'danger', icon: 'ph:trash' }, on: { press: { action: 'my-plugin:clear-cache' } } } - -// Icon-only ghost button -{ type: 'Button', props: { icon: 'ph:plus', variant: 'ghost' }, on: { press: { action: 'my-plugin:add' } } } -``` - -#### Link - -Links to `http`, `https` and `mailto` targets — anything else falls back to rendering `label` as plain text. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `href` | `string` | — | Link target | -| `label` | `string` | — | Link text (defaults to `href`) | -| `icon` | `string` | — | Iconify icon name | -| `external` | `boolean` | `true` for `http(s)` | Open in a new tab | - - -```ts -{ type: 'Link', props: { href: 'https://vite.dev', label: 'Vite docs', icon: 'ph:arrow-square-out' } } -``` - -#### TextInput - -Text input field with optional two-way state binding. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `placeholder` | `string` | — | Placeholder text | -| `value` | `string` | — | Current value (use `$bindState` for two-way binding) | -| `label` | `string` | — | Label shown above the input | -| `type` | `'text' \| 'search' \| 'number' \| 'password' \| 'email'` | `'text'` | Input type | -| `disabled` | `boolean` | `false` | Disable interaction | -| `loading` | `boolean` | `false` | Show a loading state | - - -```ts -{ - type: 'TextInput', - props: { - placeholder: 'Search modules...', - value: { $bindState: '/query' }, - }, -} -``` - -#### Select - -Dropdown choosing one value from a fixed set of options, with optional two-way state binding. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `value` | `string` | — | Current value (use `$bindState` for two-way binding) | -| `options` | `(string \| { value, label?, icon?, description? })[]` | — | Available choices | -| `placeholder` | `string` | — | Shown while `value` is unset | -| `label` | `string` | — | Label shown above the select | -| `disabled` | `boolean` | `false` | Disable interaction | -| `searchable` | `boolean` | `false` | Add a substring filter box to the panel | - -**Event**: `change` — fires when the selected value changes. - - -```ts -{ - type: 'Select', - props: { - label: 'Environment', - value: { $bindState: '/env' }, - options: [ - { value: 'dev', label: 'Development' }, - { value: 'staging', label: 'Staging' }, - { value: 'prod', label: 'Production', description: 'Live traffic' }, - ], - }, - on: { change: { action: 'my-plugin:switch-env' } }, -} -``` - -See [State and Two-Way Binding](#state-and-two-way-binding) for a full example. - -### Data display - -#### KeyValueTable - -Display key-value pairs in a two-column table. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `data` | `Record` | — | Key-value pairs to display | -| `loading` | `boolean` | `false` | Show a loading state | - - -```ts -{ - type: 'KeyValueTable', - props: { - data: { - Mode: 'production', - Duration: '1.2s', - Modules: '142', - Output: 'dist/', - }, - }, -} -``` - -#### DataTable - -Tabular data with configurable columns and scroll support. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `columns` | `Array` | — | Column definitions (a bare string uses the key as its label) | -| `rows` | `Array>` | — | Row data | -| `height` | `number` | — | Scrollable max height in pixels | -| `loading` | `boolean` | `false` | Show a loading state | - - -```ts -{ - type: 'DataTable', - props: { - columns: [ - { key: 'id', label: 'Module' }, - { key: 'size', label: 'Size' }, - { key: 'time', label: 'Transform' }, - ], - rows: [ - { id: 'src/index.ts', size: '2.1 KB', time: '12ms' }, - { id: 'src/utils.ts', size: '0.8 KB', time: '3ms' }, - { id: 'src/app.vue', size: '4.5 KB', time: '45ms' }, - ], - height: 400, - }, -} -``` - -#### CodeBlock - -Display a code snippet with an optional filename header. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `code` | `string` | — | Code content | -| `language` | `string` | — | Language identifier | -| `filename` | `string` | — | Filename shown as header | -| `height` | `number` | — | Scrollable max height in pixels | - - -```ts -{ - type: 'CodeBlock', - props: { - code: 'export default defineConfig({\n plugins: [vue()],\n})', - language: 'ts', - filename: 'vite.config.ts', - height: 200, - }, -} -``` - -#### Progress - -Progress bar with a percentage label. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `value` | `number` | — | Current value | -| `max` | `number` | `100` | Maximum value | -| `label` | `string` | — | Label text | - - -```ts -{ type: 'Progress', props: { value: 73, max: 100, label: 'Build progress' } } -``` - -#### Tree - -Expandable tree view for inspecting nested objects. - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `data` | `unknown` | — | Any JSON-serializable value | -| `defaultExpanded` | `boolean` | `true` | Whether nodes start expanded | - - -```ts -{ - type: 'Tree', - props: { - data: { - build: { - outDir: 'dist', - minify: true, - rollupOptions: { external: ['vue'] }, - }, - server: { port: 3000, hmr: true }, - }, - defaultExpanded: true, - }, -} -``` - -## Full example - -A complete panel combining layout, data display, inputs, and actions: - -```ts -import type { JsonRenderSpec, PluginWithDevTools } from '@vitejs/devtools-kit' -import { defineRpcFunction } from '@vitejs/devtools-kit' - -function buildSpec(data: { modules: number, time: string, size: string }): JsonRenderSpec { - return { - root: 'root', - state: { filter: '' }, - elements: { - 'root': { - type: 'Stack', - props: { direction: 'column', gap: 12, padding: 8 }, - children: ['header', 'divider', 'stats', 'modules'], - }, - 'header': { - type: 'Stack', - props: { direction: 'row', gap: 8, align: 'center', justify: 'between' }, - children: ['title', 'refresh-btn'], - }, - 'title': { - type: 'Text', - props: { text: 'Build Report', variant: 'heading' }, - }, - 'refresh-btn': { - type: 'Button', - props: { label: 'Refresh', icon: 'ph:arrows-clockwise' }, - on: { press: { action: 'build-report:refresh' } }, - }, - 'divider': { - type: 'Divider', - props: {}, - }, - 'stats': { - type: 'Card', - props: { title: 'Summary' }, - children: ['stats-table'], - }, - 'stats-table': { - type: 'KeyValueTable', - props: { - data: { - 'Total Modules': String(data.modules), - 'Build Time': data.time, - 'Output Size': data.size, - }, - }, - }, - 'modules': { - type: 'Card', - props: { title: 'Modules', collapsible: true }, - children: ['module-table'], - }, - 'module-table': { - type: 'DataTable', - props: { - columns: [ - { key: 'name', label: 'Module' }, - { key: 'size', label: 'Size' }, - ], - rows: [ - { name: 'src/index.ts', size: '2.1 KB' }, - { name: 'src/app.vue', size: '4.5 KB' }, - ], - height: 300, - }, - }, - }, - } -} - -export function BuildReportPlugin(): PluginWithDevTools { - return { - name: 'build-report', - devtools: { - setup(ctx) { - const data = { modules: 142, time: '1.2s', size: '48 KB' } - const ui = ctx.createJsonRenderer(buildSpec(data)) - - ctx.docks.register({ - id: 'build-report', - title: 'Build Report', - icon: 'ph:chart-bar-duotone', - type: 'json-render', - ui, - }) - - ctx.rpc.register(defineRpcFunction({ - name: 'build-report:refresh', - type: 'action', - setup: ctx => ({ - handler: async () => { - const newData = { modules: 145, time: '1.1s', size: '47 KB' } - await ui.updateSpec(buildSpec(newData)) - }, - }), - })) - }, - }, - } -} -``` - -For a more advanced plugin using json-render with per-file actions, text input with state binding, and dynamic badge updates, see the [Git UI example](/kit/examples#git-ui). diff --git a/docs/kit/messages.md b/docs/kit/messages.md index d982af41b..96fd44c02 100644 --- a/docs/kit/messages.md +++ b/docs/kit/messages.md @@ -1,215 +1,49 @@ -# Messages & Notifications - -The Messages system lets plugins emit structured message entries from both the server (Node.js) and client (browser) contexts. Entries appear in the **Messages** panel (the official [`@devframes/plugin-messages`](https://devfra.me), mounted as a built-in) and can optionally surface as toast notifications. For *coded* errors and warnings with stable codes and docs URLs, use [Structured Diagnostics](./diagnostics) (`ctx.diagnostics`) instead. - -## Use cases - -- **Accessibility audits** — run a11y checks on the client and report warnings with element positions. -- **Runtime errors** — capture and display errors with stack traces. -- **Linting & testing** — run ESLint or test runners alongside the dev server and surface results with file positions. -- **Notifications** — short-lived messages like "URL copied" that auto-dismiss. +--- +outline: deep +--- -## Message entry fields - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `message` | `string` | Yes | Short title or summary | -| `level` | `'info' \| 'warn' \| 'error' \| 'success' \| 'debug'` | Yes | Severity level, determines color and icon | -| `description` | `string` | No | Detailed description or explanation | -| `stacktrace` | `string` | No | Stack trace string | -| `filePosition` | `{ file, line?, column? }` | No | Source file location (clickable in the panel) | -| `elementPosition` | `{ selector?, boundingBox?, description? }` | No | DOM element position info | -| `notify` | `boolean` | No | Show as a toast notification | -| `category` | `string` | No | Grouping category (e.g., `'a11y'`, `'lint'`) | -| `labels` | `string[]` | No | Tags for filtering | -| `autoDismiss` | `number` | No | Time in ms to auto-dismiss the toast (default: 5000) | -| `autoDelete` | `number` | No | Time in ms to auto-delete the message entry | -| `status` | `'loading' \| 'idle'` | No | Status indicator (shows spinner when `'loading'`) | -| `id` | `string` | No | Explicit id for deduplication — re-adding with the same id updates the existing entry | +# Messages & Notifications -The `from` field is set to `'server'` or `'browser'` automatically, based on where the message was emitted. +Messages are structured entries a plugin emits from either the server or the client. They appear in the **Messages** panel (the official [`@devframes/plugin-messages`](https://devfra.me/guide/hub), mounted as a built-in) and can optionally surface as toast notifications — a11y warnings with element positions, runtime errors with stack traces, lint/test results, or short "URL copied" toasts. -## Usage +For coded errors and warnings with stable codes and docs URLs, use [Structured Diagnostics](./diagnostics) instead. -Server and client share the same `context.messages` API. Methods return Promises; for fire-and-forget usage, skip the `await`. +## Emit a message -### Fire-and-forget +Server and client share the same `ctx.messages` API. Fire-and-forget by skipping the `await`: ```ts -// No await needed — just emit the message -context.messages.add({ +ctx.messages.add({ message: 'Plugin initialized', level: 'info', }) ``` -### With handle - -`await` the `add()` call for a `DevToolsMessageHandle` you can update later: +`await` the call for a handle you can update later — handy for progress: ```ts -// Await to get a handle for later updates -const handle = await context.messages.add({ +const handle = await ctx.messages.add({ id: 'my-build', - message: 'Building...', + message: 'Building…', level: 'info', status: 'loading', }) -// Later, update via the handle -await handle.update({ - message: 'Build complete', - level: 'success', - status: 'idle', -}) - -// Or dismiss it -await handle.dismiss() +await handle.update({ message: 'Build complete', level: 'success', status: 'idle' }) ``` -### Server-side example - -```ts -export function myPlugin() { - return { - name: 'my-plugin', - devtools: { - setup(context) { - // Fire-and-forget - context.messages.add({ - message: 'Plugin initialized', - level: 'info', - }) - }, - }, - } -} -``` - -### Client-side example - -```ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default async function (context: DockClientScriptContext) { - // Await to get the handle - const message = await context.messages.add({ - message: 'Running audit...', - level: 'info', - status: 'loading', - notify: true, - }) - - // ... do work ... - - // Update via handle — can also be fire-and-forget - message.update({ - message: 'Audit complete — 3 issues found', - level: 'warn', - status: 'idle', - }) -} -``` - -## Message handle - -`context.messages.add()` returns a `Promise` with: - -| Property/Method | Description | -|-----------------|-------------| -| `handle.id` | The message entry id | -| `handle.entry` | The current `DevToolsMessageEntry` data | -| `handle.update(patch)` | Partially update the message entry (returns `Promise`) | -| `handle.dismiss()` | Remove the message entry (returns `Promise`) | - -Both `handle.update()` and `handle.dismiss()` return Promises; the `await` is optional. - -## Deduplication - -Calling `add()` with an explicit `id` that already exists updates the existing entry rather than creating a duplicate — useful for ongoing operations: - -```ts -// First call creates the entry -context.messages.add({ id: 'my-scan', message: 'Scanning...', level: 'info', status: 'loading' }) - -// Second call with same id updates it -context.messages.add({ id: 'my-scan', message: 'Scan complete', level: 'success', status: 'idle' }) -``` +## Common fields -## Toast notifications +| Field | Description | +|-------|-------------| +| `message` | Short title (required) | +| `level` | `'info' \| 'warn' \| 'error' \| 'success' \| 'debug'` (required) | +| `description` | Longer explanation | +| `filePosition` | `{ file, line?, column? }` — clickable source location | +| `elementPosition` | `{ selector?, boundingBox?, description? }` — DOM target | +| `notify` | Show as a toast | +| `category` / `labels` | Grouping and filter tags | +| `autoDismiss` | Toast lifetime in ms (default 5000) | +| `id` | Explicit id for dedup — re-adding updates the existing entry | -Set `notify: true` to surface the message entry as a toast overlay. Toasts appear whether or not the Messages panel is open. - -```ts -context.messages.add({ - message: 'URL copied to clipboard', - level: 'success', - notify: true, - autoDismiss: 2000, // disappear after 2 seconds -}) -``` - -Toast auto-dismiss defaults to 5 seconds. - -## Managing messages - -```ts -// Remove a specific message by id -context.messages.remove(entryId) - -// Clear all messages -context.messages.clear() -``` - -Capacity tops out at 1000 entries; the oldest are dropped automatically when the limit is hit. - -The [A11y Checker example](/kit/examples#a11y-checker) is a plugin that uses messages to report accessibility violations with severity levels, element positions, and WCAG labels. - -## Events - -The host emits events for observers of the message stream: - -```ts -ctx.messages.events.on('message:added', (entry) => { /* ... */ }) -ctx.messages.events.on('message:updated', (entry) => { /* ... */ }) -ctx.messages.events.on('message:removed', (id) => { /* ... */ }) -ctx.messages.events.on('message:cleared', () => { /* ... */ }) -``` - -Use the events to bridge messages into external tools — mirror them into a structured log, forward certain categories to your own reporter, etc.: - -```ts -ctx.messages.events.on('message:added', (entry) => { - if (entry.category === 'a11y') - console.log('a11y finding:', entry.message) -}) -``` - -## Long-running operation pattern - -Combine `id`-based deduplication with `status: 'loading'` to drive a single message through a multi-step lifecycle: - -```ts -async function rebuild(ctx) { - const handle = await ctx.messages.add({ - id: 'my-plugin:rebuild', - message: 'Rebuilding...', - level: 'info', - status: 'loading', - }) - - try { - await doRebuild() - await handle.update({ message: 'Rebuild complete', level: 'success', status: 'idle' }) - } - catch (error) { - await handle.update({ - message: 'Rebuild failed', - level: 'error', - description: (error as Error).message, - stacktrace: (error as Error).stack, - status: 'idle', - }) - } -} -``` +The `from` field (`'server'` or `'browser'`) is set automatically based on where you emitted the message. diff --git a/docs/kit/remote-client.md b/docs/kit/remote-client.md deleted file mode 100644 index 5f95feb81..000000000 --- a/docs/kit/remote-client.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -outline: deep ---- - -# Remote Client - -Remote client mode points a dock 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 back to the local Vite dev server and uses the same RPC and shared-state APIs as an embedded client. A live demo lives at [Remote Connection Demo](./remote-demo) — register a dock pointing at that URL to see the flow end-to-end. - -Compared to the bundled approach in [Dock System → Iframe Panels](./dock-system#iframe-panels), remote mode: - -- **Keeps your npm package small.** Ship node-side code only. -- **Decouples release cadences.** Update the hosted UI without republishing the plugin. -- **Surfaces existing dashboards.** Drop in a production URL your team already runs. - -The tradeoff: users need to be online to load the hosted page, and the hosted origin gets trusted access to render local data. - -## How it works - -When you register an iframe dock with `remote: true`, DevTools: - -1. Allocates a session-only, pre-approved auth token for that dock. -2. Injects a connection descriptor — the WS URL, the token, and the user's dev-server origin — into the iframe's `src` attribute. -3. Accepts the token on WebSocket handshake (after verifying the `Origin` header, if origin-lock is on). - -On the hosted page, `connectRemoteDevTools()` parses the descriptor out of the URL and returns a fully connected [`DevToolsRpcClient`](./rpc) — the same client you'd get from `getDevToolsRpcClient()` in an embedded page. - -```mermaid -sequenceDiagram - participant User as User app (localhost:5173) - participant Core as DevTools core - participant Hosted as Hosted page (example.com) - - User->>Core: configureServer - Core->>Core: register dock (remote: true) → allocate token - Core->>Core: createWsServer → wsEndpoint=ws://localhost:5173/__devtools/__ws - User->>Core: open dock - Core->>Hosted: iframe src + connection descriptor - Hosted->>Core: WS connect (?devframe_auth_token=...) - Core->>Core: verify token + Origin - Core-->>Hosted: trusted session - Hosted->>Core: rpc.call('my-plugin:…') -``` - -## Registering a remote dock - -```ts -import type { Plugin } from 'vite' - -export function myPlugin(): Plugin { - return { - name: 'my-plugin', - devtools: { - setup(ctx) { - ctx.docks.register({ - id: 'my-remote-tool', - title: 'My Tool', - icon: 'ph:cloud-duotone', - type: 'iframe', - url: 'https://example.com/devtools', - remote: true, - }) - }, - }, - } -} -``` - -That's the whole node-side change. The dock renders like a regular iframe panel, with the connection descriptor appended invisibly to the URL. - -### Options - -```ts -interface RemoteDockOptions { - /** @default 'fragment' */ - transport?: 'fragment' | 'query' - /** @default true */ - originLock?: boolean -} - -// in ctx.docks.register({ ... }): -// remote: true -// or: -// remote: { transport: 'query', originLock: false } -``` - -#### `transport` - -- **`'fragment'` (default)** — the descriptor rides as a URL fragment (`#vite-devtools-kit-connection=...`). Fragments stay client-side: they don't reach servers, don't enter access logs, and get stripped from `Referer` on sub-resource requests. The safest place to carry an auth token. -- **`'query'`** — the descriptor rides as a query parameter (`?vite-devtools-kit-connection=...`). Pick this when your SPA router uses the fragment for navigation, or when your hosting platform / CDN rewrites URLs in a way that drops fragments. - -> [!WARNING] -> With `'query'` transport, the auth token appears in server access logs and outbound `Referer` headers. Use it only when you control the analytics / log pipeline on the hosted origin. - -#### `originLock` - -When on (default), the WebSocket handshake is rejected if the browser's `Origin` header doesn't match the origin of the registered dock URL. If the token leaks (for example to an external analytics tool that ingests URLs), the wrong origin can't reuse it. - -Turn it off only when the same hosted app is served from multiple origins (e.g. preview deploys on `pr-123.preview.example.com`): - -```ts -ctx.docks.register({ - id: 'my-remote-tool', - title: 'My Tool', - icon: 'ph:cloud-duotone', - type: 'iframe', - url: 'https://example.com/devtools', - remote: { originLock: false }, -}) -``` - -## Connecting from the hosted page - -Install `@vitejs/devtools-kit` as a dependency of your hosted page — the client entrypoint is browser-safe: - -```sh -pnpm add @vitejs/devtools-kit -``` - -Then, on page load: - -```ts -import { connectRemoteDevTools } from '@vitejs/devtools-kit/client' - -const rpc = await connectRemoteDevTools() - -// From here, use it like any other DevToolsRpcClient: -const data = await rpc.call('my-plugin:get-data') -``` - -`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). - -When someone opens the page directly (no descriptor in the URL), the call throws. Use that as a cue to render a friendly "Open me through Vite DevTools" placeholder: - -```ts -import { connectRemoteDevTools, parseRemoteConnection } from '@vitejs/devtools-kit/client' - -if (!parseRemoteConnection()) { - renderStandaloneLandingPage() -} -else { - const rpc = await connectRemoteDevTools() - renderConnectedUi(rpc) -} -``` - -### Advanced: custom URL / options - -`connectRemoteDevTools` forwards any [`DevToolsRpcClientOptions`](./rpc) — RPC caching, custom `rpcOptions`, and so on — while keeping `connectionMeta` and `authToken` sourced from the descriptor. - -```ts -const rpc = await connectRemoteDevTools({ - cacheOptions: { maxAge: 5000 }, -}) -``` - -For testing or non-browser environments, pass an explicit URL or raw fragment/query string to `parseRemoteConnection`: - -```ts -parseRemoteConnection('https://example.com/p#vite-devtools-kit-connection=...') -parseRemoteConnection('?vite-devtools-kit-connection=...') -``` - -## Descriptor shape - -The descriptor is a superset of [`ConnectionMeta`](./rpc), so `getDevToolsRpcClient({ connectionMeta })` accepts a parsed descriptor directly: - -```ts -interface RemoteConnectionInfo { - v: 1 - backend: 'websocket' - /** Full ws:// or wss:// URL. */ - websocket: string - authToken: string - /** Dev-server origin, e.g. http://localhost:5173. */ - origin: string -} -``` - -It's JSON-encoded and base64url-encoded, then appended to the iframe URL under the parameter name `vite-devtools-kit-connection`. - -## Trust boundary - -Remote mode extends the following trust chain: - -1. The user installs your plugin and opts into DevTools. -2. Your plugin declares a remote URL. -3. DevTools hands the hosted origin a session token scoped to that URL. - -Properties of the session token: - -- **Pre-approved.** No interactive "trust this browser?" prompt fires; the user agreed to the integration when they installed the plugin. -- **Session-scoped.** Stored in memory only and regenerated on every dev-server restart. -- **Re-register-scoped.** Calling `ctx.docks.register(...)` again for the same id revokes the previous token; live WS clients on the old token receive `devframe:auth:revoked` and become untrusted. -- **Origin-locked by default.** Only connections whose `Origin` matches the dock URL are accepted. - -> [!WARNING] -> The token rides in the URL — treat it as a session secret. Avoid logging URLs to external services on the hosted page, and prefer `transport: 'fragment'` unless you have a specific reason to use `'query'`. - -## Build mode - -The WebSocket server runs in dev mode (`vite`); remote-iframe docks skip themselves in static-dump output, so no [`when` clause](./when-clauses) is needed. Add one if you want different visibility in embedded vs. standalone clients: - -```ts -ctx.docks.register({ - // ... - remote: true, - when: 'clientType == embedded', -}) -``` - -## Related - -- [Dock System](./dock-system) — the full list of dock types. -- [RPC](./rpc) — the `DevToolsRpcClient` API. -- [When Clauses](./when-clauses) — conditional dock visibility. diff --git a/docs/kit/remote-demo.md b/docs/kit/remote-demo.md index 38f2511f7..c40f532df 100644 --- a/docs/kit/remote-demo.md +++ b/docs/kit/remote-demo.md @@ -175,7 +175,7 @@ ctx.docks.register({ 1. Your plugin registers an iframe dock with `remote: true` pointing at this page. 2. The DevTools core allocates a session-only auth token and appends the connection descriptor to the iframe URL: `#vite-devtools-kit-connection=…` -3. When this page loads, [`parseRemoteConnection()`](./remote-client#connect-from-the-hosted-page) reads the descriptor, and [`connectRemoteDevTools()`](./remote-client#connect-from-the-hosted-page) opens a WebSocket back to the local dev server. +3. When this page loads, `parseRemoteConnection()` reads the descriptor, and `connectRemoteDevTools()` opens a WebSocket back to the local dev server. 4. The live "dock registry" list above subscribes to the `devframe:docks` shared state — it re-renders on every registration change. -See the [Remote Client guide](./remote-client) for the full API and the underlying security model. +Both helpers are exported from `@vitejs/devtools-kit/client`. See the [Devframe client guide](https://devfra.me/guide/client) for the browser RPC client it builds on. diff --git a/docs/kit/rpc.md b/docs/kit/rpc.md index 3b2c6c538..b8ff66cac 100644 --- a/docs/kit/rpc.md +++ b/docs/kit/rpc.md @@ -2,27 +2,13 @@ outline: deep --- -# Remote Procedure Calls (RPC) +# RPC -DevTools Kit's RPC layer is type-safe, bidirectional, and works between your Node.js server and any connected browser client. +RPC is how a DevTools integration's Node side and its browser UI talk to each other — type-safe and bidirectional. Define functions on the server with `defineRpcFunction`, then `call()` them from the client. -## Overview +The full API (schema validation, the client, birpc internals) is documented in the [Devframe RPC guide](https://devfra.me/guide/rpc); this page shows the shapes you reach for from a Vite plugin. -```mermaid -sequenceDiagram - participant Client as Browser Client - participant Server as Node.js Server - - Client->>Server: rpc.call('my-plugin:get-data', id) - Note over Server: handler: async (id) =>
fetchData(id) - Server->>Client: { id, data: '...' } -``` - -## Server-side functions - -### Defining RPC functions - -Use `defineRpcFunction` for type-safe server functions: +## Define a function ```ts import { defineRpcFunction } from '@vitejs/devtools-kit' @@ -31,602 +17,44 @@ const getModules = defineRpcFunction({ name: 'my-plugin:get-modules', type: 'query', setup: ctx => ({ - handler: async () => { - // Access DevTools context - console.log('Mode:', ctx.mode) - - return [ - { id: '/src/main.ts', size: 1024 }, - { id: '/src/App.vue', size: 2048 }, - ] - }, + handler: async () => [ + { id: '/src/main.ts', size: 1024 }, + { id: '/src/App.vue', size: 2048 }, + ], }), }) ``` -### Naming convention - -Scope each function with your package prefix and use kebab-case for the function part: `my-plugin:get-modules`, `my-plugin:read-file`. - -### Function types - -| Type | Use for | Cached | Dump support | -|------|---------|--------|--------------| -| `query` | Fetch data, read operations | Yes | Manual | -| `static` | Constant data | Indefinitely | Automatic | -| `action` | Side effects, mutations | No | — | -| `event` | Notifications without a response | No | — | - -For chunk-style data (LLM deltas, log lines, build progress, file uploads), reach for [streaming channels](./streaming) — they handle stream IDs, cancellation, replay, and Web Streams interop for you. - -### Handler arguments - -Handlers can accept any serializable arguments: - -```ts -const getModule = defineRpcFunction({ - name: 'my-plugin:get-module', - type: 'query', - setup: () => ({ - handler: async (id: string, options?: { includeSource: boolean }) => { - // id and options are passed from the client - return { id, source: options?.includeSource ? '...' : undefined } - }, - }), -}) -``` - -### Context in setup - -The `setup` function receives the full `ViteDevToolsNodeContext`: - -```ts -setup: (ctx) => { - // Access Vite config - const root = ctx.viteConfig.root - - // Access dev server (if in dev mode) - const server = ctx.viteServer - - return { - handler: async () => { - // Use ctx here too - return { root, mode: ctx.mode } - }, - } -} -``` - -For build-mode compatibility, compute data in `setup` using the context and let the handler use it. The dump feature then pre-computes results at build time using values that already exist in `setup`'s closure. - -### Registering functions - -Register the RPC function from `devtools.setup`: +Register it in `devtools.setup`: ```ts const plugin: Plugin = { devtools: { setup(ctx) { ctx.rpc.register(getModules) - } - } -} -``` - -### Dump feature for build mode - -A static DevTools build (via `vite devtools build` or the [`build.withApp`](/guide/#building-with-the-app) plugin option) has no live server. The dump feature pre-computes RPC results at build time and bakes them into the static output. - -#### How it works - -1. At build time, `dumpFunctions()` runs each RPC handler with predefined arguments. -2. Results land in `__rpc-dump/index.json` (and sharded `__rpc-dump/*.json` files) in the build output. -3. The static client reads from those files instead of making live RPC calls. - -Function names in shard file keys replace `:` with `~` (e.g. `my-plugin:get-data` → `my-plugin~get-data`). Query record maps are embedded directly in `__rpc-dump/index.json`. - -#### Static functions - -Functions with `type: 'static'` are dumped automatically with no arguments — the recommended default for constant data: - -```ts -const getConfig = defineRpcFunction({ - name: 'my-plugin:get-config', - type: 'static', // Auto-dumped with inputs: [[]] - setup: ctx => ({ - handler: async () => ({ - root: ctx.viteConfig.root, - plugins: ctx.viteConfig.plugins.map(p => p.name), - }), - }), -}) -``` - -Works in both dev mode (live) and build mode (pre-computed). - -#### Query functions with dumps - -For `query` functions that need arguments, define `dump` in the setup: - -```ts -const getModule = defineRpcFunction({ - name: 'my-plugin:get-module', - type: 'query', - setup: (ctx) => { - // Collect all module IDs at build time - const moduleIds = Array.from(ctx.viteServer?.moduleGraph?.idToModuleMap.keys() || []) - - return { - handler: async (id: string) => { - const module = ctx.viteServer?.moduleGraph?.getModuleById(id) - return module ? { id, size: module.transformResult?.code.length } : null - }, - dump: { - inputs: moduleIds.map(id => [id]), // Pre-compute for all modules - fallback: null, // Return null for unknown modules - }, - } - }, -}) -``` - -#### Recommendations for plugin authors - -For DevTools that work in both dev and build: - -1. Prefer `type: 'static'` for functions that return constant data. -2. Compute context-based data in `setup` rather than accessing global state in handlers. -3. Define `dump` in `setup` for query functions that need pre-computation. -4. Provide fallback values so unmatched arguments degrade gracefully. - -```ts -// ✓ Good: returns static data, works in build mode -const getPluginInfo = defineRpcFunction({ - name: 'my-plugin:info', - type: 'static', - setup: ctx => ({ - handler: async () => ({ - version: '1.0.0', - root: ctx.viteConfig.root, - }), - }), -}) - -// ✗ Avoid: depends on runtime server state, dev-mode only -const getLiveMetrics = defineRpcFunction({ - name: 'my-plugin:metrics', - type: 'query', - handler: async () => { - return getCurrentMetrics() // requires live server - }, -}) -``` - -`type: 'query'` without a dump still works in dev mode — use it when the data genuinely needs live server state. - -### Organization convention - -For plugin-scale RPC modules, we recommend: - -1. One RPC function per file — small and focused. -2. `src/node/rpc/index.ts` as the single composition point for registration and type augmentation. -3. Plugin-specific runtime options stored in `src/node/rpc/context.ts` rather than mutated onto the base DevTools context. -4. `context.rpc.invokeLocal(...)` for server-side cross-function composition. - -Rough file tree: - -```text -src/node/rpc/ -├─ index.ts # exports rpcFunctions + module augmentation -├─ context.ts # WeakMap-backed helpers (set/get shared rpc context) -└─ functions/ - ├─ get-info.ts # metadata-style query/static function - ├─ list-files.ts # list operation, reusable by other functions - ├─ read-file.ts # can invoke `list-files` via invokeLocal - └─ write-file.ts # mutation-oriented function -``` - -1. `src/node/rpc/index.ts` -Keep all RPC declarations in one exported list (for example `rpcFunctions`) and centralize type augmentation (`DevToolsRpcServerFunctions`) in the same file. - -```ts -// src/node/rpc/index.ts -import type { RpcDefinitionsToFunctions } from '@vitejs/devtools-kit' -import { getInfo } from './functions/get-info' -import { listFiles } from './functions/list-files' -import { readFile } from './functions/read-file' -import '@vitejs/devtools-kit' - -export const rpcFunctions = [ - getInfo, - listFiles, - readFile, -] as const // use `as const` to allow type inference - -export type ServerFunctions = RpcDefinitionsToFunctions - -declare module '@vitejs/devtools-kit' { - export interface DevToolsRpcServerFunctions extends ServerFunctions {} -} -``` - -2. `src/node/rpc/context.ts` -Use a shared context helper (for example `WeakMap`-backed `set/get`) to provide plugin-specific options across RPC functions without mutating the base context shape. - -```ts -// src/node/rpc/context.ts -import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' - -const rpcContext = new WeakMap() - -export function setRpcContext(context: ViteDevToolsNodeContext, options: { targetDir: string }) { - rpcContext.set(context, options) -} - -export function getRpcContext(context: ViteDevToolsNodeContext) { - const value = rpcContext.get(context) - if (!value) - throw new Error('Missing RPC context') - return value -} -``` - -```ts -// plugin setup -const plugin = { - devtools: { - setup(context) { - setRpcContext(context, { targetDir: 'src' }) - rpcFunctions.forEach(fn => context.rpc.register(fn)) - }, - }, -} -``` - -3. `src/node/rpc/functions/read-file.ts` -For cross-function calls on the server, use `context.rpc.invokeLocal(':list-files')` rather than network-style calls. - -```ts -// src/node/rpc/functions/read-file.ts -export const readFile = defineRpcFunction({ - name: 'my-plugin:read-file', - type: 'query', - dump: async (context) => { - const files = await context.rpc.invokeLocal('my-plugin:list-files') - return { - inputs: files.map(file => [file.path] as [string]), - } - }, - setup: () => ({ - handler: async (path: string) => { - // ... - }, - }), -}) -``` - -The [File Explorer example](/kit/examples#file-explorer) follows these conventions for a plugin with RPC functions and dump support. - -## Schema validation - -The RPC system supports runtime schema validation through [Valibot](https://valibot.dev). When you provide schemas, TypeScript types are inferred and validation runs at the call site. Schemas are optional — without them, RPC works on plain TypeScript types. - -```ts -import { defineRpcFunction } from '@vitejs/devtools-kit' -import * as v from 'valibot' - -const getModule = defineRpcFunction({ - name: 'my-plugin:get-module', - type: 'query', - args: [ - v.string(), - v.optional(v.object({ - includeSource: v.boolean(), - })), - ], - returns: v.object({ - id: v.string(), - source: v.optional(v.string()), - }), - setup: () => ({ - handler: (id, options) => { - // Types are automatically inferred from schemas - // id: string - // options: { includeSource: boolean } | undefined - return { - id, - source: options?.includeSource ? '...' : undefined, - } - }, - }), -}) -``` - -## Client-side calls - -### In iframe pages - -Use `getDevToolsRpcClient()` to get the RPC client: - -```ts -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' - -async function loadData() { - const rpc = await getDevToolsRpcClient() - - // Call server function - const modules = await rpc.call('my-plugin:get-modules') - - // With arguments - const module = await rpc.call('my-plugin:get-module', '/src/main.ts', { - includeSource: true, - }) -} -``` - -### In action/renderer scripts - -Use `ctx.rpc` from the script context: - -```ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default function setup(ctx: DockClientScriptContext) { - ctx.current.events.on('entry:activated', async () => { - const data = await ctx.rpc.call('my-plugin:get-modules') - console.log(data) - }) -} -``` - -### Sharing state across RPC functions - -When multiple RPC functions need the same plugin-specific state (a manager instance, plugin options, cached data), key a `WeakMap` by `ViteDevToolsNodeContext`. This keeps the plugin state scoped, garbage-collectable, and out of the base context. - -Create a helper file with get/set functions: - -```ts -// src/node/rpc/context.ts -import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' - -interface MyPluginContext { - dataDir: string - manager: DataManager -} - -const pluginContext = new WeakMap() - -export function getPluginContext(ctx: ViteDevToolsNodeContext): MyPluginContext { - const value = pluginContext.get(ctx) - if (!value) - throw new Error('Plugin context not initialized') - return value -} - -export function setPluginContext(ctx: ViteDevToolsNodeContext, value: MyPluginContext) { - pluginContext.set(ctx, value) -} -``` - -Initialize the state in your plugin's `devtools.setup`, then access it from any RPC function's `setup`: - -::: code-group - -```ts [plugin.ts] -import { rpcFunctions } from './rpc' -import { setPluginContext } from './rpc/context' - -const plugin: Plugin = { - devtools: { - setup(ctx) { - setPluginContext(ctx, { - dataDir: resolve(ctx.cwd, 'data'), - manager: new DataManager(), - }) - rpcFunctions.forEach(fn => ctx.rpc.register(fn)) }, }, } ``` -```ts [functions/get-data.ts] -import { defineRpcFunction } from '@vitejs/devtools-kit' -import { getPluginContext } from '../context' - -export const getData = defineRpcFunction({ - name: 'my-plugin:get-data', - type: 'query', - setup: (ctx) => { - const { manager } = getPluginContext(ctx) - return { - handler: async () => manager.getData(), - } - }, -}) -``` - -::: +Scope every function id with your package prefix and use kebab-case: `my-plugin:get-modules`. -### Global client context +## Function types -Beyond RPC, the full client context — docks, commands, panel state — is available anywhere in the host page via `getDevToolsClientContext()`. See [Client Script & Context](/kit/client-context). +| Type | Use for | Cached | +|------|---------|--------| +| `query` | Read operations, fetch data | Yes | +| `static` | Constant data | Indefinitely | +| `action` | Side effects, mutations | No | +| `event` | Notifications without a response | No | -## Client-side functions - -The client can also expose functions that the server calls. - -### Registering client functions +## Call from the client ```ts -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' +import { getDevToolsClientContext } from '@vitejs/devtools-kit/client' -export default function setup(ctx: DockClientScriptContext) { - ctx.rpc.client.register({ - name: 'my-plugin:highlight-element', - type: 'action', - handler: (selector: string) => { - const el = document.querySelector(selector) - if (el) { - el.style.outline = '2px solid red' - setTimeout(() => { - el.style.outline = '' - }, 2000) - } - }, - }) -} -``` - -### Broadcasting from server - -`ctx.rpc.broadcast()` sends an event-style call to every connected client and resolves once dispatch completes: - -```ts -const plugin: Plugin = { - devtools: { - setup(ctx) { - // Later, when you want to notify clients... - ctx.rpc.broadcast({ - method: 'my-plugin:highlight-element', - args: ['#app'], - }) - } - } -} -``` - -## Type safety - -Extend the DevTools Kit interfaces for end-to-end type checking. - -### Server functions - -```ts -// src/types.ts -import '@vitejs/devtools-kit' - -declare module '@vitejs/devtools-kit' { - interface DevToolsRpcServerFunctions { - 'my-plugin:get-modules': () => Promise - 'my-plugin:get-module': ( - id: string, - options?: { includeSource: boolean } - ) => Promise - } -} - -interface Module { - id: string - size: number - source?: string -} -``` - -### Client functions - -```ts -// src/types.ts -declare module '@vitejs/devtools-kit' { - interface DevToolsRpcClientFunctions { - 'my-plugin:highlight-element': (selector: string) => void - 'my-plugin:refresh-ui': () => void - } -} -``` - -Now TypeScript will autocomplete and validate your RPC calls: - -```ts -// ✓ Type-checked +const { rpc } = getDevToolsClientContext()! const modules = await rpc.call('my-plugin:get-modules') - -// ✓ Argument types validated -const module = await rpc.call('my-plugin:get-module', '/src/main.ts') - -// ✗ Error: unknown function name -const data = await rpc.call('my-plugin:unknown') -``` - -## Complete example - -A plugin with both server and client RPC functions: - -::: code-group - -```ts [plugin.ts] -/// -import type { Plugin } from 'vite' -import { defineRpcFunction } from '@vitejs/devtools-kit' - -export default function analyticsPlugin(): Plugin { - const metrics = new Map() - - return { - name: 'analytics', - - transform(code, id) { - metrics.set(id, code.length) - }, - - devtools: { - setup(ctx) { - // Server function: get metrics - ctx.rpc.register( - defineRpcFunction({ - name: 'analytics:get-metrics', - type: 'query', - setup: () => ({ - handler: async () => Object.fromEntries(metrics), - }), - }) - ) - - // Broadcast to clients when metrics change - ctx.viteServer?.watcher.on('change', (file) => { - ctx.rpc.broadcast({ - method: 'analytics:metrics-updated', - args: [file], - }) - }) - }, - }, - } -} -``` - -```ts [client.ts] -import type { DockClientScriptContext } from '@vitejs/devtools-kit/client' - -export default function setup(ctx: DockClientScriptContext) { - // Register client function - ctx.rpc.client.register({ - name: 'analytics:metrics-updated', - type: 'action', - handler: (file: string) => { - console.log('File changed:', file) - refreshUI() - }, - }) - - async function refreshUI() { - const metrics = await ctx.rpc.call('analytics:get-metrics') - console.log('Updated metrics:', metrics) - } -} -``` - -```ts [types.ts] -import '@vitejs/devtools-kit' - -declare module '@vitejs/devtools-kit' { - interface DevToolsRpcServerFunctions { - 'analytics:get-metrics': () => Promise> - } - - interface DevToolsRpcClientFunctions { - 'analytics:metrics-updated': (file: string) => void - } -} ``` -::: +For chunked data — LLM deltas, log lines, build progress, uploads — use [streaming channels](https://devfra.me/guide/streaming) instead of plain functions. diff --git a/docs/kit/shared-state.md b/docs/kit/shared-state.md index 5630ce3a3..76d865b4c 100644 --- a/docs/kit/shared-state.md +++ b/docs/kit/shared-state.md @@ -4,406 +4,47 @@ outline: deep # Shared State -DevTools Kit's shared-state system synchronizes data between server and clients. Changes on either side propagate to every connected party. +Shared state synchronizes data between the Node side and every connected browser client. A change on either end propagates to all the others, and the value survives reconnects — reach for it instead of RPC when a UI should reactively track server data. -## Overview +The full reactive API is in the [Devframe shared-state guide](https://devfra.me/guide/shared-state); this page shows the Vite-plugin usage. -```mermaid -flowchart LR - subgraph ClientA["Client A"] - A_Value["state.value()
{ count: 1 }"] - end +## Server side - subgraph Server["Server"] - S_Mutate["state.mutate()
{ count: 1 }"] - end - - subgraph ClientB["Client B"] - B_Value["state.value()
{ count: 1 }"] - end - - S_Mutate <-->|RPC sync| A_Value - S_Mutate <-->|RPC sync| B_Value -``` - -## Server-side usage - -### Creating shared state - -`ctx.rpc.sharedState.get()` creates or returns shared state: +`ctx.rpc.sharedState.get()` creates or returns a keyed state: ```ts const plugin: Plugin = { devtools: { async setup(ctx) { const state = await ctx.rpc.sharedState.get('my-plugin:state', { - initialValue: { - count: 0, - items: [], - settings: { theme: 'dark' }, - }, + initialValue: { count: 0, items: [] as string[] }, }) - // Read current value - console.log(state.value()) - // => { count: 0, items: [], settings: { theme: 'dark' } } - } - } -} -``` - -### Reading state + state.value() // read: { count: 0, items: [] } -```ts -const state = await ctx.rpc.sharedState.get('my-plugin:state', { - initialValue: { count: 0 }, -}) - -// Get current value -const current = state.value() -console.log(current.count) // 0 -``` - -### Mutating state - -`state.mutate()` updates the state and syncs the change to every connected client: - -```ts -// Mutate with a function (recommended) -state.mutate((draft) => { - draft.count += 1 - draft.items.push({ id: 1, name: 'New item' }) -}) -``` - -The mutation function receives a mutable draft; changes are batched and synced automatically. - -### Example: real-time updates - -```ts -const plugin: Plugin = { - devtools: { - async setup(ctx) { - const state = await ctx.rpc.sharedState.get('my-plugin:state', { - initialValue: { modules: [], lastUpdate: 0 }, + state.mutate((draft) => { + draft.count++ }) - - // Update state when Vite processes modules - ctx.viteServer?.watcher.on('change', (file) => { - state.mutate((draft) => { - draft.modules.push(file) - draft.lastUpdate = Date.now() - }) - }) - } - } -} -``` - -## Client-side usage - -### Accessing shared state - -`client.sharedState.get()` returns the shared state from the client: - -```ts -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' - -const client = await getDevToolsRpcClient() - -const state = await client.sharedState.get('my-plugin:state') - -// Read current value -console.log(state.value()) -``` - -The [global client context](/kit/client-context) exposes the same API via `ctx.rpc.sharedState`. - -### Subscribing to changes - -`state.on('updated', ...)` reacts to state changes: - -```ts -const state = await client.sharedState.get('my-plugin:state') - -// Initial value -console.log(state.value()) // { count: 0 } - -// Subscribe to updates -state.on('updated', (newState) => { - console.log('State updated:', newState) - // { count: 1 } - after server mutation -}) -``` - -## Framework integration - -### Vue - -A reactive ref that syncs with shared state: - -```ts -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' -import { shallowRef } from 'vue' - -export async function useSharedState(name: string) { - const client = await getDevToolsRpcClient() - const sharedState = await client.sharedState.get(name) - - const state = shallowRef(sharedState.value()) - - sharedState.on('updated', (newState) => { - state.value = newState - }) - - return state -} - -// Usage in component -const state = await useSharedState('my-plugin:state') -// `state` is now reactive and auto-updates -``` - -### Vue composable (full example) - -```vue - - - -``` - -### React - -```tsx -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' -import { useEffect, useState } from 'react' - -function useSharedState(name: string, fallback: T) { - const [state, setState] = useState(fallback) - - useEffect(() => { - let mounted = true - - async function init() { - const client = await getDevToolsRpcClient() - const sharedState = await client.sharedState.get(name) - - if (mounted) { - setState(sharedState.value() ?? fallback) - - sharedState.on('updated', (newState) => { - if (mounted) { - setState(newState) - } - }) - } - } - - init() - - return () => { - mounted = false - } - }, [name]) - - return state -} - -// Usage -function MyComponent() { - const state = useSharedState('my-plugin:state', { count: 0 }) - - return ( -
- Count: - {state.count} -
- ) -} -``` - -### Svelte - -```svelte - - -

Count: {$state.count}

-``` - -## Type safety - -Extend `DevToolsRpcSharedStates` for type-safe shared state: - -```ts -// src/types.ts -import '@vitejs/devtools-kit' - -interface MyPluginState { - count: number - items: Array<{ id: string, name: string }> - settings: { - theme: 'light' | 'dark' - notifications: boolean - } -} - -declare module '@vitejs/devtools-kit' { - interface DevToolsRpcSharedStates { - 'my-plugin:state': MyPluginState - } -} -``` - -Now TypeScript will validate your state access: - -```ts -const state = await ctx.rpc.sharedState.get('my-plugin:state', { - initialValue: { - count: 0, - items: [], - settings: { theme: 'dark', notifications: true }, + }, }, -}) - -// ✓ Type-checked -state.mutate((draft) => { - draft.count += 1 - draft.settings.theme = 'light' -}) - -// ✗ Error: 'invalid' is not assignable to 'light' | 'dark' -state.mutate((draft) => { - draft.settings.theme = 'invalid' -}) -``` - -## Best practices - -### Namespaced keys - -Prefix state keys with your plugin name to avoid collisions: - -```ts -// ✓ Good -'my-plugin:state' -'my-plugin:settings' - -// ✗ Bad — may conflict with other plugins -'state' -'settings' -``` - -### Keep state serializable - -Shared state travels over JSON. Stick to plain data — no functions, no circular references: - - - -```ts -// ✗ Bad — functions don't serialize -{ - count: 0, - increment: () => this.count++ -} - -// ✗ Bad — circular references -const obj = { child: null } -obj.child = obj - -// ✓ Good — plain data -{ - count: 0, - items: [{ id: 1, name: 'Item' }] } ``` -### Batch updates +## Client side -Group multiple changes into a single `mutate` call to broadcast one sync event: +Get the same key from the client context and subscribe to changes: ```ts -// ✓ Good — single sync event -state.mutate((draft) => { - draft.count += 1 - draft.lastUpdate = Date.now() - draft.items.push(newItem) -}) +import { getDevToolsClientContext } from '@vitejs/devtools-kit/client' -// ✗ Bad — three sync events -state.mutate((d) => { - d.count += 1 -}) -state.mutate((d) => { - d.lastUpdate = Date.now() +const { rpc } = getDevToolsClientContext()! +const state = await rpc.sharedState.get('my-plugin:state', { + initialValue: { count: 0, items: [] }, }) -state.mutate((d) => { - d.items.push(newItem) + +state.subscribe((value) => { + console.log('updated', value) }) ``` -### Mind state size - -Large state objects can drag on sync performance. For large datasets, keep IDs in shared state and fetch the full records via RPC on demand: - - - -```ts -// ✓ Store just IDs and fetch details via RPC -{ - moduleIds: ['a', 'b', 'c'], - selectedModule: 'a' -} - -const module = await rpc.call('my-plugin:get-module', state.selectedModule) -``` +Keep values serializable, and namespace keys with your package prefix (`my-plugin:state`). diff --git a/docs/kit/streaming.md b/docs/kit/streaming.md deleted file mode 100644 index bc3cb4971..000000000 --- a/docs/kit/streaming.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -outline: deep ---- - -# Streaming - -DevTools Kit ships a streaming-channel API for chunk-style data flowing in either direction between server and client — chat deltas, log lines, build progress, file uploads, mic / screen-share frames. - -Reach for streaming when you need: - -- Token-by-token rendering with low latency (LLM deltas, terminal output). -- Per-call lifecycles with cooperative cancellation. -- Replay on reconnect — a panel reopened mid-stream picks up where it left off. -- Client-to-server uploads without inventing a multipart protocol. - -For *snapshot* state that survives reconnect and syncs across panels, use [shared state](./shared-state) instead. - -## Overview - -```mermaid -sequenceDiagram - participant Producer as Producer (server) - participant Channel as ctx.rpc.streaming
channel - participant Browser as Subscriber (browser) - - Producer->>Channel: start({ id }) - Channel-->>Browser: chunk(seq=1, "...") - Channel-->>Browser: chunk(seq=2, "...") - Producer->>Channel: close() - Channel-->>Browser: end() -``` - -A **channel** owns a wire namespace. Each call to `channel.start()` produces an individual **stream** keyed by an id (auto-generated unless you pass one). Subscribers join by `(channelName, id)`. - -## Server-to-client (the common case) - -### Defining a channel - -Create the channel once in `devtools.setup`: - -```ts -/// -import type { Plugin } from 'vite' -import { defineRpcFunction } from '@vitejs/devtools-kit' -import * as v from 'valibot' - -export default function chatPlugin(): Plugin { - return { - name: 'my-plugin', - devtools: { - async setup(ctx) { - const channel = ctx.rpc.streaming.create('my-plugin:chat', { - replayWindow: 256, - }) - - ctx.rpc.register(defineRpcFunction({ - name: 'my-plugin:start-chat', - type: 'action', - jsonSerializable: true, - args: [v.object({ prompt: v.string() })], - returns: v.object({ streamId: v.string() }), - handler: async ({ prompt }) => { - const stream = channel.start() - ;(async () => { - for await (const token of fakeLLM(prompt, { signal: stream.signal })) { - if (stream.signal.aborted) - break - stream.write(token) - } - stream.close() - })() - return { streamId: stream.id } - }, - })) - }, - }, - } -} -``` - -Channel names follow the `:` convention used by RPC functions and shared-state keys. - -### Producing — three surfaces, one stream - -The handle returned by `channel.start({ id? })` is both an imperative producer and a Web Streams `WritableStream`: - -```ts -const stream = channel.start({ id: 'optional-explicit-id' }) - -// Imperative — minimal, hand-rolled producers -stream.write(chunk) -stream.error(err) // terminal failure -stream.close() // terminal success -stream.signal // AbortSignal — flips when consumers cancel -stream.id // string — what clients subscribe to - -// Web Streams — pipe any ReadableStream in: -sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) - -// Convenience — start + pipe in one call: -const stream2 = await channel.pipeFrom(sourceReadable) -``` - -Producers should poll `stream.signal.aborted` and exit cooperatively when it flips: - -```ts -for (const token of source) { - if (stream.signal.aborted) - return - stream.write(token) -} -stream.close() -``` - -#### Node.js stream interop - -Web Streams are the canonical surface. Node 17+ ships standard-library converters for bridging to `node:stream`: - -```ts -import { Readable, Writable } from 'node:stream' - -// Pipe a Node Readable into the streaming channel -sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) - -// Pipe the channel out to a Node Writable -Readable.fromWeb(reader.readable).pipe(targetNodeWritable) -``` - -### Consuming — `for await` or `pipeTo` - -The client returns a reader that's both an `AsyncIterable` and exposes a `ReadableStream`: - -```ts -import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' - -const rpc = await getDevToolsRpcClient() -const { streamId } = await rpc.call('my-plugin:start-chat', { - prompt: 'Hello', -}) - -const reader = rpc.streaming.subscribe('my-plugin:chat', streamId) - -// Async iterable — the simplest consumer pattern -for await (const token of reader) - appendToken(token) - -// Or pipe to a DOM-side WritableStream -await reader.readable.pipeTo(downloadWritable) - -reader.cancel() // sends cancel upstream; server stream.signal flips -``` - -Use one surface per reader — they share a single internal queue, so concurrent draining races. - -## Client-to-server uploads - -The same channel works in reverse for chunk-style uploads — file content, mic / screen-share frames, browser-side logs forwarded to disk, anything that would otherwise need a hand-rolled multipart-over-HTTP. The pattern: one regular RPC call allocates the id, then dedicated streaming events carry the chunks. - -```ts -// Server — typically inside an action handler -ctx.rpc.register(defineRpcFunction({ - name: 'my-plugin:upload-file', - type: 'action', - args: [v.object({ name: v.string() })], - returns: v.object({ uploadId: v.string() }), - handler: async ({ name }) => { - const reader = channel.openInbound() - - // Process chunks asynchronously — the action returns immediately - // so the client can start uploading. - ;(async () => { - const file = createWriteStream(name) - for await (const chunk of reader) - file.write(chunk) - file.close() - })() - - return { uploadId: reader.id } - }, -})) -``` - -```ts -// Client -const { uploadId } = await rpc.call('my-plugin:upload-file', { - name: 'capture.bin', -}) -const upload = rpc.streaming.upload('my-plugin:files', uploadId) - -// Imperative -upload.write(chunk1) -upload.write(chunk2) -upload.close() - -// Or pipe a Web ReadableStream straight in: -fileReadable.pipeTo(upload.writable, { signal: upload.signal }) -``` - -Lifecycle mirrors the outbound case: - -- `upload.signal` aborts when the **server** calls `reader.cancel()` (the server cancellation broadcasts an `upload-cancel` to the uploading session). -- `upload.error(err)` propagates as a thrown error inside the server's `for await`. -- If the client disconnects mid-upload, the server's `for await` exits with an `UploadDisconnected` error so consumers can clean up. - -Each `openInbound()` allocates a fresh server-side id owned by exactly one uploading session. Uploads are point-to-point: one producer, no fan-in, no shared subscribers, no replay (reconnect means the client restarts). - -## Lifecycle and cancellation - -| Event | Server side | Client side | -|-------|-------------|-------------| -| Producer calls `stream.close()` / `stream.error(err)` | Broadcasts `end` to subscribers | `for await` resolves (success) or throws (error) | -| Consumer calls `reader.cancel()` | `stream.signal` aborts when the **last** subscriber cancels — handlers should poll and exit | Reader marks itself cancelled; `for await` ends without iterating | -| WS disconnects | When the **last** subscriber drops, server aborts `stream.signal` | Reader stays alive; resubscribes automatically when trust is re-established | -| Panel closes mid-stream | Reader cancel cascades upstream | — | - -A stream with multiple subscribers stays alive until the last one cancels or disconnects. Producers should make `stream.signal.aborted` part of their inner loop. - -## Replay on reconnect - -With `replayWindow: N`, the server keeps a rolling buffer of the last `N` chunks per stream. On (re)subscribe, the client passes the highest sequence number it has seen, and the server replays anything newer before resuming live. - -```ts -ctx.rpc.streaming.create('my-plugin:chat', { - replayWindow: 256, // chunks to retain per stream id - closedStreamRetention: 30_000, // ms to hold closed streams for late subscribers -}) -``` - -`closedStreamRetention` defaults to 30 seconds when `replayWindow > 0` (so a panel re-opened seconds after a chat finishes still gets the full transcript). Set it explicitly to tune retention. - -## Backpressure - -The client maintains a bounded queue per subscription (`highWaterMark`, default 256). When the consumer falls behind, the oldest queued chunk drops and a [`DF0029`](https://devfra.me/errors/DF0029) warning is logged. This is best-effort — sufficient for current streaming use cases without threading transport-level backpressure through the RPC layer. - -```ts -const reader = rpc.streaming.subscribe('my-plugin:chat', id, { - highWaterMark: 1024, // raise if you expect bursts the consumer can recover from -}) -``` - -When you need authoritative state rather than every intermediate value, [shared state](./shared-state) carries Immer patches with delivery guarantees — structured rather than streaming. - -## Combining streaming with shared state - -Token-level streaming and shared-state snapshots compose naturally for chat-style UIs: - -- The conversation **log** lives in shared state (survives reloads, syncs across panels). -- Active responses use a **streaming** channel for low-latency token rendering. -- The action that starts a response appends a user message + assistant placeholder to shared state, kicks off the producer, and on producer close commits the joined content back to shared state. - -```ts -const channel = ctx.rpc.streaming.create('my-plugin:chat-tokens', { - replayWindow: 1024, -}) -const history = await ctx.rpc.sharedState.get('my-plugin:chat-history', { - initialValue: { messages: [] as ChatMessage[] }, -}) - -ctx.rpc.register(defineRpcFunction({ - name: 'my-plugin:send', - type: 'action', - args: [v.object({ prompt: v.string() })], - returns: v.object({ streamId: v.string(), assistantId: v.string() }), - handler: async ({ prompt }) => { - const stream = channel.start() - const assistantId = crypto.randomUUID() - - history.mutate((draft) => { - draft.messages.push({ - id: crypto.randomUUID(), - role: 'user', - content: prompt, - }) - draft.messages.push({ - id: assistantId, - role: 'assistant', - content: '', - streamId: stream.id, - }) - }) - - let acc = '' - ;(async () => { - for await (const token of fakeLLM(prompt, { signal: stream.signal })) { - if (stream.signal.aborted) - break - stream.write(token) - acc += token - } - stream.close() - // Commit final content; clients now read the message from - // shared state and drop the live overlay. - history.mutate((draft) => { - const msg = draft.messages.find(m => m.id === assistantId) - if (msg) { - msg.content = acc - msg.streamId = undefined - } - }) - })() - - return { streamId: stream.id, assistantId } - }, -})) -``` - -A working version of this pattern lives in [`devframe-streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/devframe-streaming-chat). - -## When to use streaming vs events vs shared state - -| Use streaming for | Use `event`-typed RPC for | Use shared state for | -|-------------------|---------------------------|----------------------| -| Token / chunk feeds (LLM deltas, build logs) | Notifications without payload (`refresh`, `clear`) | Long-lived UI state (selections, panel layout) | -| Per-call lifecycles with cancellation | Cross-cutting signals broadcast to all clients | Reactive snapshots that survive reconnect | -| Replay on reconnect | Fire-and-forget signaling | Diff-based sync between clients | -| Client-to-server uploads (files, mic frames) | | | - -## Reference - -The API surface — `RpcStreamingHost`, `RpcStreamingChannel`, `StreamSink`, `StreamReader` — is re-exported from `@vitejs/devtools-kit`. Streaming is built on the same primitive as Devframe's [streaming guide](https://devfra.me/guide/streaming); error codes for backpressure and lifecycle live there: [`DF0029`](https://devfra.me/errors/DF0029), [`DF0030`](https://devfra.me/errors/DF0030), [`DF0031`](https://devfra.me/errors/DF0031), [`DF0032`](https://devfra.me/errors/DF0032). diff --git a/docs/kit/terminals.md b/docs/kit/terminals.md index 867789b13..e875e4a3b 100644 --- a/docs/kit/terminals.md +++ b/docs/kit/terminals.md @@ -2,13 +2,11 @@ outline: deep --- -# Terminals & Subprocesses +# Terminals & Processes -DevTools Kit's terminal host lets a plugin spawn and manage child processes. Sessions are aggregated by the hub and surfaced in the Terminals panel (the official [`@devframes/plugin-terminals`](https://devfra.me), mounted as a built-in), which streams their output in real time. +`ctx.terminals` lets a plugin spawn and manage child processes. The hub aggregates every session into the **Terminals** panel (the official [`@devframes/plugin-terminals`](https://devfra.me/guide/hub), mounted as a built-in) and streams their output live — the same primitive [install launchers](./dock-system#install-launchers) use to track an install. -## Starting a child process - -The primary API is `ctx.terminals.startChildProcess()`: +## Start a process ```ts const session = await ctx.terminals.startChildProcess( @@ -26,136 +24,15 @@ const session = await ctx.terminals.startChildProcess( ) ``` -The first argument describes what to execute: - -```ts -interface DevToolsChildProcessExecuteOptions { - command: string - args: string[] - cwd?: string - env?: Record -} -``` - -The second argument provides terminal metadata (id, title, and optional description/icon). - -### Returned session - -`startChildProcess()` returns a `DevToolsChildProcessTerminalSession` with lifecycle controls: - -```ts -// Terminate the process -await session.terminate() - -// Restart (kill + re-spawn) -await session.restart() - -// Access the underlying Node.js ChildProcess -const cp = session.getChildProcess() -``` - -The spawned process gets `FORCE_COLOR=true` and `COLORS=true` so terminal output stays coloured by default. - -## Combining with launcher docks - -Pair a [launcher dock entry](/kit/dock-system#launcher-entries) with a terminal session for a one-button start: - -```ts -ctx.docks.register({ - id: 'my-plugin:launcher', - title: 'My App', - icon: 'ph:rocket-launch-duotone', - type: 'launcher', - launcher: { - title: 'Start My App', - description: 'Launch the dev server', - onLaunch: async () => { - await ctx.terminals.startChildProcess( - { - command: 'vite', - args: ['dev'], - cwd: process.cwd(), - }, - { - id: 'my-plugin:dev-server', - title: 'Dev Server', - }, - ) - }, - }, -}) -``` - -## Custom terminal sessions - -To stream from any source — external logs, custom protocols, anything yielding strings — register a session with a `ReadableStream`: - -```ts -let controller: ReadableStreamDefaultController - -const stream = new ReadableStream({ - start(c) { - controller = c - }, -}) - -ctx.terminals.register({ - id: 'my-plugin:custom-stream', - title: 'Custom Output', - status: 'running', - stream, -}) - -// Push data to the terminal -controller.enqueue('Hello from custom stream!\n') -``` - -## Session lifecycle - -Each terminal session has a `status` field: - -| Status | Description | -|--------|-------------| -| `running` | Process is active and streaming output | -| `stopped` | Process exited normally | -| `error` | Process exited with an error | - -Update a session's metadata or status at any time: - -```ts -ctx.terminals.update({ - id: 'my-plugin:build-watcher', - status: 'stopped', - title: 'Build Watcher (done)', -}) -``` - -## Events - -Subscribe to lifecycle changes (register, update, remove) via the host event emitter: - -```ts -ctx.terminals.events.on('terminal:session:updated', (session) => { - console.log(session.id, session.status) -}) -``` - -Output chunks travel through the [streaming channel](/kit/streaming) `devframe:terminals`, keyed by session id. The kit's `DevToolsTerminalHost` already pipes each session's `ReadableStream` into the channel; this matters only when building a custom terminal renderer: - -```ts -const reader = rpc.streaming.subscribe( - 'devframe:terminals', - sessionId, -) -for await (const chunk of reader) writeToTerminal(chunk) -``` +The first argument is what to execute (`command`, `args`, optional `cwd` / `env`); the second is terminal metadata. Spawned processes get `FORCE_COLOR=true` so output stays colored. -## Inspection +## Control a session ```ts -for (const session of ctx.terminals.sessions.values()) { - console.log(session.id, session.title, session.status) -} +await session.terminate() // kill +await session.restart() // kill + re-spawn +const result = await session.getResult() // { exitCode, ... } +const cp = session.getChildProcess() // the Node ChildProcess ``` -`ctx.terminals.sessions` is a live `Map` — useful for diagnostics, testing, and custom terminal UIs that mirror the Terminals panel. +Pass a session's id to a `launcher` dock entry's `terminalSessionId` to give the card a "View in Terminal" link. See the [Devframe hub docs](https://devfra.me/guide/hub) for the full terminal API. diff --git a/docs/kit/when-clauses.md b/docs/kit/when-clauses.md deleted file mode 100644 index 94ae5f996..000000000 --- a/docs/kit/when-clauses.md +++ /dev/null @@ -1,263 +0,0 @@ ---- -outline: deep ---- - -# When Clauses - -When clauses are conditional expressions that control visibility and activation of commands and dock entries. The expression language matches [VS Code's when-clause contexts](https://code.visualstudio.com/api/references/when-clause-contexts), evaluated against a reactive context object. - -The evaluator is [`whenexpr`](https://github.com/antfu/whenexpr), which also provides the `WhenExpression` type helper used by `defineCommand` / `defineDockEntry` for compile-time validation — see [Type-safe `when` clauses](#type-safe-when-clauses). - -## Usage - -### On commands - -Controls whether the command appears in the palette and whether it can be triggered via shortcuts: - -```ts -ctx.commands.register(defineCommand({ - id: 'my-plugin:embedded-only', - title: 'Embedded-Only Action', - when: 'clientType == embedded', - handler: async () => { /* ... */ }, -})) -``` - -### On dock entries - -Controls whether a dock entry is visible in the dock bar: - -```ts -ctx.docks.register(defineDockEntry({ - id: 'my-plugin:inspector', - title: 'Inspector', - type: 'action', - icon: 'ph:cursor-duotone', - when: 'clientType == embedded', - action: { importFrom: 'my-plugin/inspector' }, -})) -``` - -`when: 'false'` hides a dock entry unconditionally. - -### Render-only visibility - -`visibility` is a narrower, render-only counterpart to `when`: it hides only the entry's own dock-bar button, while the entry stays registered and fully reachable — `docks.activate()`/`switchEntry()` by id, RPC lookups, and anything else that walks the raw entry list keep working exactly as if it were visible. - -The canonical use case is a shared-frame [`subTabs`](/kit/dock-system#shared-iframe-soft-navigation) anchor: the anchor iframe must stay registered to keep driving the postMessage nav loop, but only its synthesized member tabs should render as dock-bar buttons. - -```ts -ctx.docks.register(defineDockEntry({ - id: 'my-plugin:anchor', - title: 'Anchor', - type: 'iframe', - url: '/my-plugin/', - icon: 'ph:cursor-duotone', - visibility: 'false', // no button of its own — only its subTabs members render - subTabs: { /* ... */ }, -})) -``` - -## Expression syntax - -### Operators - -| Category | Operators | Example | -| ----------- | -------------------------------- | -------------------------------- | -| Bare truthy | identifier | `dockOpen` | -| Literals | `true`, `false`, numbers, strings | `true`, `42`, `'dev'` | -| Unary | `!`, `-`, `+` | `!paletteOpen` | -| Logical | `&&`, `\|\|` | `dockOpen && !paletteOpen` | -| Equality | `==`, `!=`, `===`, `!==` | `clientType == embedded` | -| Relational | `<`, `<=`, `>`, `>=` | `count >= 10` | -| Arithmetic | `+`, `-`, `*`, `/`, `%` | `(a + b) * c` | -| Grouping | `( … )` | `(a \|\| b) && c` | - -### Precedence (low to high) - -`||` → `&&` → equality → relational → `+ -` → `* / %` → unary → primary - -### `==` vs `===` - -- **`==` / `!=`** — VS Code when-clause idiom. Right-hand side is a single value token (bare identifier, quoted string, number, or boolean) and comparison is done as a string. - ```ts - evaluateWhen('clientType == embedded', ctx) - ``` -- **`===` / `!==`** — JavaScript strict equality. Both sides are full expressions, no coercion. - ```ts - evaluateWhen('count === 1', { count: 1 }) // true - evaluateWhen('count === 1', { count: '1' }) // false - ``` - -### Examples - -```ts -// Always visible -when: 'true' - -// Never visible (unconditionally hidden) -when: 'false' - -// Only in embedded mode -when: 'clientType == embedded' - -// Only when dock is open and palette is closed -when: 'dockOpen && !paletteOpen' - -// Compound with parentheses -when: '(clientType == embedded && dockOpen) || clientType == standalone' - -// Plugin-specific context -when: 'vite.mode == development' -``` - -## Built-in context variables - -| Variable | Type | Description | -|----------|------|-------------| -| `clientType` | `'embedded' \| 'standalone'` | Current client mode. `embedded` when running inside the host app overlay, `standalone` in a separate window. | -| `dockOpen` | `boolean` | Whether the dock panel is currently open | -| `paletteOpen` | `boolean` | Whether the command palette is currently open | -| `dockSelectedId` | `string` | ID of the currently selected dock entry. Empty string `''` (falsy) when no dock is selected. | - -## Namespaced context keys - -Plugins register context variables under namespaced keys (`.` or `:` separators) to avoid collisions. Use your plugin id as the prefix — `my-plugin.featureEnabled`, `rolldown:buildStep`. - -```ts -// Flat key (recommended) -context['vite.mode'] = 'development' -context['vite:buildMode'] = 'lib' - -// Nested object (also supported) -context.vite = { mode: 'development', ssr: true } -``` - -Both styles can be used in `when` expressions: - -```ts -when: 'vite.mode == development' -when: 'vite:buildMode == lib' -when: 'vite.ssr' -``` - -### Lookup order - -When resolving a namespaced key like `vite.mode`: - -1. **Exact match** — `ctx['vite.mode']` is checked first. -2. **Nested path** — `ctx.vite?.mode` is the fallback. - -Flat keys take priority over nested objects when both exist. - -## Type-safe `when` clauses - -`defineCommand` and `defineDockEntry` capture the `when:` string as a TypeScript literal and validate it against `WhenContext` through [`whenexpr`](https://github.com/antfu/whenexpr)'s `WhenExpression` helper. Syntax errors surface as compile-time errors at the call site. `defineDockEntry` validates `visibility:` the same way. - -```ts -import { defineCommand } from '@vitejs/devtools-kit' - -defineCommand({ - id: 'my-plugin:toggle', - title: 'Toggle', - when: 'dockOpen && !paletteOpen', // ✓ ok - handler: async () => { /* ... */ }, -}) - -defineCommand({ - id: 'my-plugin:broken', - title: 'Broken', - when: 'dockOpen &&& !paletteOpen', - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type error: syntax error - handler: async () => { /* ... */ }, -}) -``` - -### Key validation with plugin-specific contexts - -The default `WhenContext` uses `[key: string]: unknown` to keep namespaced plugin keys open-ended; that means built-in syntax validation alone won't catch typos like `'dockOpn == embedded'`. For key-name validation in your plugin, define a narrower context shape and build a plugin-specific `define*` wrapper: - -```ts -import type { WhenContext, WhenExpression } from '@vitejs/devtools-kit' - -interface MyPluginContext extends Omit { - 'clientType': 'embedded' | 'standalone' - 'dockOpen': boolean - 'paletteOpen': boolean - 'dockSelectedId': string - 'my-plugin.featureEnabled': boolean -} - -function defineMyCommand(cmd: { - id: string - title: string - when?: WhenExpression - handler: (...args: any[]) => Promise -}): typeof cmd { - return cmd -} - -defineMyCommand({ - id: 'my-plugin:toggle', - title: 'Toggle', - when: 'my-plugin.featureEnabled && dockOpen', // ✓ ok - handler: async () => { /* ... */ }, -}) - -defineMyCommand({ - id: 'my-plugin:broken', - title: 'Broken', - when: 'my-plugin.featureEnable', // ← typo - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type error: Unknown context key - handler: async () => { /* ... */ }, -}) -``` - -## API reference - -The when-clause evaluator is provided by `devframe`: - -```ts -import type { WhenContext } from '@vitejs/devtools-kit' -import { evaluateWhen, resolveContextValue } from 'devframe/utils/when' - -const ctx: WhenContext = { - 'clientType': 'embedded', - 'dockOpen': true, - 'paletteOpen': false, - 'dockSelectedId': 'my-dock', - 'vite.mode': 'development', -} - -evaluateWhen('dockOpen && vite.mode == development', ctx) // true -evaluateWhen('clientType == standalone', ctx) // false - -resolveContextValue('vite.mode', ctx) // 'development' -resolveContextValue('dockOpen', ctx) // true -``` - -### `evaluateWhen(expression, ctx, options?)` - -Evaluates a when-clause expression string against a context object. Returns `boolean`. Pass `{ strict: true }` to throw on unknown context keys — useful for catching typos during development. - -### `resolveContextValue(key, ctx)` - -Resolves a single context key (including namespaced keys) from the context object. Returns `unknown`. - -### `WhenContext` - -The context interface with built-in variables and an index signature for custom keys: - -```ts -interface WhenContext { - clientType: 'embedded' | 'standalone' - dockOpen: boolean - paletteOpen: boolean - dockSelectedId: string - [key: string]: unknown // custom plugin variables -} -``` - -### `WhenExpression` - -Branded expression type re-exported from `whenexpr`. Use it to build your own typed `define*` helpers — see [Key validation with plugin-specific contexts](#key-validation-with-plugin-specific-contexts) above. diff --git a/playgrounds/core/vite.config.ts b/playgrounds/core/vite.config.ts index 15c6af505..06354f0b7 100644 --- a/playgrounds/core/vite.config.ts +++ b/playgrounds/core/vite.config.ts @@ -83,7 +83,7 @@ export default defineConfig({ Tracer({ viteDevtools: true, }), - // Kit-pattern json-render demo — see docs/kit/examples.md. + // Kit-pattern json-render demo. GitUIPlugin(), // Official @devframes/plugin-* dashboards.