diff --git a/docs/kit/rpc.md b/docs/kit/rpc.md index 27c711fc..6c1f1a5e 100644 --- a/docs/kit/rpc.md +++ b/docs/kit/rpc.md @@ -44,19 +44,16 @@ const getModules = defineRpcFunction({ }) ``` -### Registering Functions +### Naming Convention -Register your RPC function in the `devtools.setup`: +Recommended RPC function naming: -```ts -const plugin: Plugin = { - devtools: { - setup(ctx) { - ctx.rpc.register(getModules) - } - } -} -``` +1. Scope functions with your package prefix: `:...` +2. Use kebab-case for the function part after `:` + +Examples: +- `my-plugin:get-modules` +- `my-plugin:read-file` ### Function Types @@ -108,6 +105,20 @@ setup: (ctx) => { > [!IMPORTANT] > For build mode compatibility, compute data in the setup function using the context rather than relying on runtime global state. This allows the dump feature to pre-compute results at build time. +### Registering Functions + +Register your RPC function in the `devtools.setup`: + +```ts +const plugin: Plugin = { + devtools: { + setup(ctx) { + ctx.rpc.register(getModules) + } + } +} +``` + ### Dump Feature for Build Mode When using `vite devtools build` to create a static DevTools build, the server cannot execute functions at runtime. The **dump feature** solves this by pre-computing RPC results at build time. @@ -115,9 +126,12 @@ When using `vite devtools build` to create a static DevTools build, the server c #### How It Works 1. At build time, `dumpFunctions()` executes your RPC handlers with predefined arguments -2. Results are stored in `.vdt-rpc-dump.json` in the build output +2. Results are stored in `.rpc-dump/index.json` in the build output 3. The static client reads from this JSON file instead of making live RPC calls +Dump shard files are written to `.rpc-dump/*.json`. Function names in shard file keys replace `:` with `~` (for example `my-plugin:get-data` -> `my-plugin~get-data`). +Query record maps are embedded directly in `.rpc-dump/index.json`; no per-function index files are generated. + #### Static Functions (Recommended) Functions with `type: 'static'` are **automatically dumped** with no arguments: @@ -198,6 +212,109 @@ const getLiveMetrics = defineRpcFunction({ > [!TIP] > If your data genuinely needs live server state, use `type: 'query'` without dumps. The function will work in dev mode but gracefully fail in build mode. +### Organization Convention + +For plugin-scale RPC modules, we recommend this structure: + +General guidelines: + +1. Keep function definitions small and focused: one RPC function per file. +2. Use `src/node/rpc/index.ts` as the single composition point for registration and type augmentation. +3. Store plugin-specific runtime options in `src/node/rpc/context.ts` (instead of mutating the base DevTools context object). +4. Use `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 { DevToolsNodeContext } from '@vitejs/devtools-kit' + +const rpcContext = new WeakMap() + +export function setRpcContext(context: DevToolsNodeContext, options: { targetDir: string }) { + rpcContext.set(context, options) +} + +export function getRpcContext(context: DevToolsNodeContext) { + 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) => { + // ... + }, + }), +}) +``` + ## Schema Validation (Optional) The RPC system has built-in support for runtime schema validation using [Valibot](https://valibot.dev). When you provide schemas, TypeScript types are automatically inferred and validation happens at runtime. diff --git a/examples/plugin-file-explorer/README.md b/examples/plugin-file-explorer/README.md new file mode 100644 index 00000000..6656f71c --- /dev/null +++ b/examples/plugin-file-explorer/README.md @@ -0,0 +1,68 @@ +# Example: DevTools Kit File Explorer Plugin + +This example shows how to build a custom Vite DevTools panel with `@vitejs/devtools-kit`. + +It provides a **File Explorer** dock that: +- lists files under a target directory +- reads file content on demand +- writes file content in dev (websocket) mode +- works in static mode via RPC dump files + +## How It Works + +The example has three main parts: + +1. Node plugin (`src/node/plugin.ts`) +- creates RPC functions +- registers them with `context.rpc.register(...)` +- hosts the built UI with `context.views.hostStatic(...)` +- registers a dock entry (`type: 'iframe'`) for the panel + +2. RPC functions (`src/node/rpc/functions/*`) +- `plugin-file-explorer:get-info` (`type: 'static'`) +- `plugin-file-explorer:list-files` (`type: 'query'`, dumped with empty args) +- `plugin-file-explorer:read-file` (`type: 'query'`, fallback `null`) +- `plugin-file-explorer:write-file` (`type: 'action'`, dev-only behavior) + +3. UI app (`src/ui/main.tsx`) +- connects using `getDevToolsRpcClient()` +- detects backend mode (`websocket` vs `static`) +- hides write controls in static mode + +## Run The Example + +From the `examples/plugin-file-explorer` directory (`cd examples/plugin-file-explorer`): + +```bash +pnpm play:dev +``` + +Then open the app URL, open Vite DevTools, and switch to the **File Explorer** dock. + +## Static Build / Preview + +Build static output: + +```bash +pnpm play:build +``` + +Preview generated static files: + +```bash +pnpm play:preview +``` + +Static artifacts are generated under: + +- `playground/.vite-devtools/.devtools/.connection.json` +- `playground/.vite-devtools/.devtools/.rpc-dump/index.json` +- `playground/.vite-devtools/.devtools/.rpc-dump/*.json` + +## Notes + +- Default UI base: `/.plugin-file-explorer/` +- Default target directory: `src` +- You can override via options or env: + - `KIT_PLUGIN_FILE_EXPLORER_UI_BASE` + - `KIT_PLUGIN_FILE_EXPLORER_TARGET_DIR` diff --git a/examples/plugin-file-explorer/package.json b/examples/plugin-file-explorer/package.json new file mode 100644 index 00000000..04a3849a --- /dev/null +++ b/examples/plugin-file-explorer/package.json @@ -0,0 +1,41 @@ +{ + "name": "example-plugin-file-explorer", + "type": "module", + "version": "0.0.0", + "private": true, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build:node": "tsdown --config-loader=tsx", + "build:ui": "cd src/ui && vite build", + "build": "pnpm run build:node && pnpm run build:ui", + "play:dev": "pnpm run build && cd playground && DEBUG='vite:devtools:*' vite", + "play:build": "pnpm run build && cd playground && vite build && vite-devtools build", + "play:preview": "serve ./playground/.vite-devtools" + }, + "peerDependencies": { + "vite": "*" + }, + "dependencies": { + "@vitejs/devtools": "workspace:*", + "@vitejs/devtools-kit": "workspace:*", + "pathe": "catalog:deps", + "tinyglobby": "catalog:deps" + }, + "devDependencies": { + "@types/react": "catalog:types", + "@types/react-dom": "catalog:types", + "react": "catalog:frontend", + "react-dom": "catalog:frontend", + "serve": "catalog:devtools", + "tsdown": "catalog:build", + "unocss": "catalog:build", + "vite": "catalog:build" + } +} diff --git a/examples/plugin-file-explorer/playground/index.html b/examples/plugin-file-explorer/playground/index.html new file mode 100644 index 00000000..e0c2a204 --- /dev/null +++ b/examples/plugin-file-explorer/playground/index.html @@ -0,0 +1,12 @@ + + + + + + Kit Plugin File Explorer Playground + + +
+ + + diff --git a/examples/plugin-file-explorer/playground/src/main.ts b/examples/plugin-file-explorer/playground/src/main.ts new file mode 100644 index 00000000..b1733ea6 --- /dev/null +++ b/examples/plugin-file-explorer/playground/src/main.ts @@ -0,0 +1,34 @@ +import '@unocss/reset/tailwind.css' +import 'virtual:uno.css' + +// @unocss-include + +const app = document.querySelector('#app') +if (!app) + throw new Error('Missing #app root') + +app.innerHTML = ` +
+
+
+

Kit Plugin File Explorer Playground

+

+ Open Vite DevTools and switch to File Explorer. + The panel lists files under playground/src and loads file contents on demand. + Save is available in websocket mode and hidden in static build mode. +

+
+

Try this:

+
    +
  1. Select src/main.ts in the File Explorer dock.
  2. +
  3. Edit a sentence and click Save in websocket mode.
  4. +
  5. Run static build and confirm write controls are hidden.
  6. +
+

+ This playground keeps the source folder intentionally small so file operations are easy to inspect. +

+
+
+
+
+` diff --git a/examples/plugin-file-explorer/playground/src/vite-env.d.ts b/examples/plugin-file-explorer/playground/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/plugin-file-explorer/playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/plugin-file-explorer/playground/vite.config.ts b/examples/plugin-file-explorer/playground/vite.config.ts new file mode 100644 index 00000000..f9c84cca --- /dev/null +++ b/examples/plugin-file-explorer/playground/vite.config.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url' +import { DevTools } from '@vitejs/devtools' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import kitPluginFileExplorer from '../src/node' + +const unoConfig = fileURLToPath(new URL('../uno.config.ts', import.meta.url)) + +export default defineConfig({ + plugins: [ + DevTools({ + builtinDevTools: false, + }), + kitPluginFileExplorer(), + UnoCSS({ + configFile: unoConfig, + }), + ], + build: { + rollupOptions: { + devtools: {}, + }, + }, +}) diff --git a/examples/plugin-file-explorer/src/node/constants.ts b/examples/plugin-file-explorer/src/node/constants.ts new file mode 100644 index 00000000..3559304f --- /dev/null +++ b/examples/plugin-file-explorer/src/node/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_UI_BASE = '/.plugin-file-explorer/' +export const DEFAULT_TARGET_DIR = 'src' diff --git a/examples/plugin-file-explorer/src/node/index.ts b/examples/plugin-file-explorer/src/node/index.ts new file mode 100644 index 00000000..4b1dc1f8 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/index.ts @@ -0,0 +1,3 @@ +export { default } from './plugin' +export * from './plugin' +export * from './types' diff --git a/examples/plugin-file-explorer/src/node/plugin.ts b/examples/plugin-file-explorer/src/node/plugin.ts new file mode 100644 index 00000000..07fb2bed --- /dev/null +++ b/examples/plugin-file-explorer/src/node/plugin.ts @@ -0,0 +1,48 @@ +import type { PluginWithDevTools } from '@vitejs/devtools-kit' +import type { KitPluginFileExplorerOptions, KitPluginFileExplorerResolvedOptions } from './types' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'pathe' +import { rpcFunctions } from './rpc' +import { setFileExplorerOptions } from './rpc/context' +import { resolveTargetDir, resolveUiBase } from './utils' + +export { DEFAULT_TARGET_DIR, DEFAULT_UI_BASE } from './constants' +export { resolveUiBase } from './utils' + +export function createKitPluginFileExplorerDevToolsPlugin(options: KitPluginFileExplorerOptions = {}): PluginWithDevTools { + const uiBase = resolveUiBase(options.uiBase) + const targetDir = resolveTargetDir(options.targetDir) + const resolvedOptions: KitPluginFileExplorerResolvedOptions = { + uiBase, + targetDir, + } + + return { + name: 'kit-plugin-file-explorer-devtools', + devtools: { + setup(context) { + setFileExplorerOptions(context, resolvedOptions) + + const distFromBundle = fileURLToPath(new URL('./ui', import.meta.url)) + const distFromSource = fileURLToPath(new URL('../../dist/ui', import.meta.url)) + const iframeDist = fs.existsSync(distFromBundle) ? distFromBundle : distFromSource + + for (const fn of rpcFunctions) { + context.rpc.register(fn) + } + + context.views.hostStatic(uiBase, resolve(iframeDist)) + context.docks.register({ + id: 'plugin-file-explorer:file-explorer', + title: 'File Explorer', + icon: 'ph:folder-open-duotone', + type: 'iframe', + url: uiBase, + }) + }, + }, + } +} + +export default createKitPluginFileExplorerDevToolsPlugin diff --git a/examples/plugin-file-explorer/src/node/rpc/context.ts b/examples/plugin-file-explorer/src/node/rpc/context.ts new file mode 100644 index 00000000..1963a448 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/context.ts @@ -0,0 +1,19 @@ +import type { DevToolsNodeContext } from '@vitejs/devtools-kit' +import type { KitPluginFileExplorerResolvedOptions } from '../types' + +const fileExplorerOptions = new WeakMap() + +export function setFileExplorerOptions( + context: DevToolsNodeContext, + options: KitPluginFileExplorerResolvedOptions, +): void { + fileExplorerOptions.set(context, options) +} + +export function getFileExplorerOptions(context: DevToolsNodeContext): KitPluginFileExplorerResolvedOptions { + const options = fileExplorerOptions.get(context) + if (!options) { + throw new Error('[kit-plugin-file-explorer] Missing plugin options in context. Ensure setup calls setFileExplorerOptions(context, options) before registering RPC functions.') + } + return options +} diff --git a/examples/plugin-file-explorer/src/node/rpc/functions/get-info.ts b/examples/plugin-file-explorer/src/node/rpc/functions/get-info.ts new file mode 100644 index 00000000..224b9095 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/functions/get-info.ts @@ -0,0 +1,24 @@ +import type { FileExplorerInfo } from '../../types' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { resolve } from 'pathe' +import { getFileExplorerOptions } from '../context' + +export const fileExplorerGetInfo = defineRpcFunction< + 'plugin-file-explorer:get-info', + 'static', + [], + Promise +>({ + name: 'plugin-file-explorer:get-info', + type: 'static', + setup: async (context) => { + const { targetDir } = getFileExplorerOptions(context) + const rootDir = resolve(context.cwd, targetDir) + const info: FileExplorerInfo = { + rootDir, + } + return { + handler: async () => info, + } + }, +}) diff --git a/examples/plugin-file-explorer/src/node/rpc/functions/list-files.ts b/examples/plugin-file-explorer/src/node/rpc/functions/list-files.ts new file mode 100644 index 00000000..ce3f8d08 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/functions/list-files.ts @@ -0,0 +1,51 @@ +import type { FileExplorerFileEntry } from '../../types' +import fs from 'node:fs' +import { stat } from 'node:fs/promises' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { extname, relative, resolve } from 'pathe' +import { glob } from 'tinyglobby' +import { toPosixPath } from '../../utils' +import { getFileExplorerOptions } from '../context' + +export const fileExplorerListFiles = defineRpcFunction< + 'plugin-file-explorer:list-files', + 'query', + [], + Promise +>({ + name: 'plugin-file-explorer:list-files', + type: 'query', + setup: async (context) => { + return { + handler: async () => { + const { targetDir } = getFileExplorerOptions(context) + const rootDir = resolve(context.cwd, targetDir) + const absoluteFiles = fs.existsSync(rootDir) + ? await glob(['**/*'], { + cwd: rootDir, + absolute: true, + onlyFiles: true, + dot: false, + }) + : [] + const files = await Promise.all( + absoluteFiles + .sort((a, b) => a.localeCompare(b)) + .map(async (absolutePath): Promise => { + const filePath = toPosixPath(relative(rootDir, absolutePath)) + const fileStat = await stat(absolutePath) + return { + path: filePath, + size: fileStat.size, + ext: extname(filePath), + } + }), + ) + return files + }, + dump: { + inputs: [[]] as const, + }, + } + }, +}) diff --git a/examples/plugin-file-explorer/src/node/rpc/functions/read-file.ts b/examples/plugin-file-explorer/src/node/rpc/functions/read-file.ts new file mode 100644 index 00000000..7412d4e7 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/functions/read-file.ts @@ -0,0 +1,53 @@ +import type { FileExplorerFileDetail, FileExplorerFileEntry, ResolvedFilePath } from '../../types' +import { readFile, stat } from 'node:fs/promises' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { resolve } from 'pathe' +import { resolveSafePath } from '../../utils' +import { getFileExplorerOptions } from '../context' + +export const fileExplorerReadFile = defineRpcFunction< + 'plugin-file-explorer:read-file', + 'query', + [path: string], + Promise +>({ + name: 'plugin-file-explorer:read-file', + type: 'query', + setup: async (context) => { + return { + handler: async (path: string): Promise => { + const { targetDir } = getFileExplorerOptions(context) + const rootDir = resolve(context.cwd, targetDir) + let resolved: ResolvedFilePath + + try { + resolved = resolveSafePath(rootDir, path) + } + catch { + return null + } + + try { + const content = await readFile(resolved.absolutePath, 'utf-8') + const fileStat = await stat(resolved.absolutePath) + if (!fileStat.isFile()) + return null + return { + path: resolved.relativePath, + content, + size: fileStat.size, + } + } + catch { + return null + } + }, + } + }, + dump: async (context) => { + const files = await context.rpc.invokeLocal('plugin-file-explorer:list-files') as FileExplorerFileEntry[] + return { + inputs: files.map(file => [file.path] as [string]), + } + }, +}) diff --git a/examples/plugin-file-explorer/src/node/rpc/functions/write-file.ts b/examples/plugin-file-explorer/src/node/rpc/functions/write-file.ts new file mode 100644 index 00000000..b298bf97 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/functions/write-file.ts @@ -0,0 +1,28 @@ +import { stat, writeFile } from 'node:fs/promises' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { resolve } from 'pathe' +import { resolveSafePath } from '../../utils' +import { getFileExplorerOptions } from '../context' + +export const fileExplorerWriteFile = defineRpcFunction< + 'plugin-file-explorer:write-file', + 'action', + [path: string, content: string], + Promise +>({ + name: 'plugin-file-explorer:write-file', + type: 'action', + setup: async (context) => { + return { + handler: async (path: string, content: string): Promise => { + const { targetDir } = getFileExplorerOptions(context) + const rootDir = resolve(context.cwd, targetDir) + const resolved = resolveSafePath(rootDir, path) + const fileStat = await stat(resolved.absolutePath) + if (!fileStat.isFile()) + throw new Error(`File is not a regular file: ${JSON.stringify(path)}`) + await writeFile(resolved.absolutePath, content, 'utf-8') + }, + } + }, +}) diff --git a/examples/plugin-file-explorer/src/node/rpc/index.ts b/examples/plugin-file-explorer/src/node/rpc/index.ts new file mode 100644 index 00000000..4ac6df49 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/rpc/index.ts @@ -0,0 +1,19 @@ +import type { RpcDefinitionsToFunctions } from '@vitejs/devtools-kit' +import { fileExplorerGetInfo } from './functions/get-info' +import { fileExplorerListFiles } from './functions/list-files' +import { fileExplorerReadFile } from './functions/read-file' +import { fileExplorerWriteFile } from './functions/write-file' +import '@vitejs/devtools-kit' + +export const rpcFunctions = [ + fileExplorerGetInfo, + fileExplorerListFiles, + fileExplorerReadFile, + fileExplorerWriteFile, +] as const + +export type ServerFunctions = RpcDefinitionsToFunctions + +declare module '@vitejs/devtools-kit' { + export interface DevToolsRpcServerFunctions extends ServerFunctions {} +} diff --git a/examples/plugin-file-explorer/src/node/types.ts b/examples/plugin-file-explorer/src/node/types.ts new file mode 100644 index 00000000..01e32576 --- /dev/null +++ b/examples/plugin-file-explorer/src/node/types.ts @@ -0,0 +1,30 @@ +export interface FileExplorerInfo { + rootDir: string +} + +export interface FileExplorerFileEntry { + path: string + size: number + ext: string +} + +export interface FileExplorerFileDetail { + path: string + content: string + size: number +} + +export interface ResolvedFilePath { + absolutePath: string + relativePath: string +} + +export interface KitPluginFileExplorerOptions { + uiBase?: string + targetDir?: string +} + +export interface KitPluginFileExplorerResolvedOptions { + uiBase: string + targetDir: string +} diff --git a/examples/plugin-file-explorer/src/node/utils.ts b/examples/plugin-file-explorer/src/node/utils.ts new file mode 100644 index 00000000..a88c6adb --- /dev/null +++ b/examples/plugin-file-explorer/src/node/utils.ts @@ -0,0 +1,46 @@ +import type { ResolvedFilePath } from './types' +import process from 'node:process' +import { relative, resolve } from 'pathe' +import { DEFAULT_TARGET_DIR, DEFAULT_UI_BASE } from './constants' + +export function resolveUiBase(base = process.env.KIT_PLUGIN_FILE_EXPLORER_UI_BASE || DEFAULT_UI_BASE): string { + let normalized = base.trim() + if (!normalized.startsWith('/')) + normalized = `/${normalized}` + if (!normalized.endsWith('/')) + normalized = `${normalized}/` + return normalized +} + +export function resolveTargetDir(targetDir = process.env.KIT_PLUGIN_FILE_EXPLORER_TARGET_DIR || DEFAULT_TARGET_DIR): string { + const normalized = targetDir + .trim() + .replace(/^[/\\]+/, '') + .replace(/[/\\]+$/, '') + return normalized || DEFAULT_TARGET_DIR +} + +export function toPosixPath(path: string): string { + return path.replace(/\\/g, '/') +} + +export function resolveSafePath(rootDir: string, path: string): ResolvedFilePath { + const normalized = toPosixPath(path.trim()).replace(/^\/+/, '') + + if (!normalized || normalized.includes('\0')) + throw new Error(`Invalid file path: ${JSON.stringify(path)}`) + + if (normalized.split('/').includes('..')) + throw new Error(`Path traversal is not allowed: ${JSON.stringify(path)}`) + + const absolutePath = resolve(rootDir, normalized) + const relativePath = toPosixPath(relative(rootDir, absolutePath)) + + if (!relativePath || relativePath.startsWith('../') || relativePath === '..') + throw new Error(`Path is outside of root directory: ${JSON.stringify(path)}`) + + return { + absolutePath, + relativePath, + } +} diff --git a/examples/plugin-file-explorer/src/ui/index.html b/examples/plugin-file-explorer/src/ui/index.html new file mode 100644 index 00000000..6ad5bd78 --- /dev/null +++ b/examples/plugin-file-explorer/src/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Kit Plugin File Explorer DevTools + + +
+ + + diff --git a/examples/plugin-file-explorer/src/ui/main.tsx b/examples/plugin-file-explorer/src/ui/main.tsx new file mode 100644 index 00000000..5615f470 --- /dev/null +++ b/examples/plugin-file-explorer/src/ui/main.tsx @@ -0,0 +1,305 @@ +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' +import React from 'react' +import { createRoot } from 'react-dom/client' +import '@unocss/reset/tailwind.css' +import 'virtual:uno.css' + +const GET_INFO_RPC = 'plugin-file-explorer:get-info' +const LIST_FILES_RPC = 'plugin-file-explorer:list-files' +const READ_FILE_RPC = 'plugin-file-explorer:read-file' +const WRITE_FILE_RPC = 'plugin-file-explorer:write-file' +const rpcPromise = getDevToolsRpcClient() + +interface FileExplorerInfo { + rootDir: string +} + +interface FileEntry { + path: string + size: number + ext: string +} + +interface FileDetail { + path: string + content: string + size: number +} + +function formatBytes(size: number): string { + if (size < 1024) + return `${size} B` + if (size < 1024 * 1024) + return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +function Stat(props: { label: string, value: string | number }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function App() { + const [rpc, setRpc] = React.useState> | null>(null) + const [isStaticMode, setIsStaticMode] = React.useState(false) + const [info, setInfo] = React.useState(null) + const [files, setFiles] = React.useState([]) + const [selectedPath, setSelectedPath] = React.useState(null) + const [detail, setDetail] = React.useState(null) + const [draft, setDraft] = React.useState('') + const [isLoadingFile, setIsLoadingFile] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [status, setStatus] = React.useState(null) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + let active = true + ;(async () => { + try { + const client = await rpcPromise + if (!active) + return + setRpc(client) + setIsStaticMode(client.connectionMeta.backend === 'static') + + const [infoValue, filesValue] = await Promise.all([ + client.call(GET_INFO_RPC) as Promise, + client.call(LIST_FILES_RPC) as Promise, + ]) + + if (!active) + return + + const sortedFiles = filesValue.toSorted((a, b) => a.path.localeCompare(b.path)) + + setInfo(infoValue) + setFiles(sortedFiles) + setSelectedPath(sortedFiles[0]?.path ?? null) + } + catch (err) { + if (!active) + return + setError((err as Error).message) + } + })() + + return () => { + active = false + } + }, []) + + React.useEffect(() => { + if (!rpc || !selectedPath) { + setDetail(null) + setDraft('') + return + } + + let active = true + setIsLoadingFile(true) + setError(null) + setStatus(null) + + rpc.call(READ_FILE_RPC, selectedPath) + .then((value) => { + if (!active) + return + const file = value as FileDetail | null + setDetail(file) + setDraft(file?.content ?? '') + }) + .catch((err) => { + if (!active) + return + setError((err as Error).message) + }) + .finally(() => { + if (active) + setIsLoadingFile(false) + }) + + return () => { + active = false + } + }, [rpc, selectedPath]) + + async function saveCurrentFile() { + if (!rpc || !selectedPath || isStaticMode) + return + + setIsSaving(true) + setStatus(null) + setError(null) + + try { + await rpc.call(WRITE_FILE_RPC, selectedPath, draft) + + const size = new TextEncoder().encode(draft).length + setDetail((prev) => { + if (!prev || prev.path !== selectedPath) + return prev + return { + ...prev, + content: draft, + size, + } + }) + setFiles(prev => prev.map(file => file.path === selectedPath ? { ...file, size } : file)) + setStatus(`Saved ${selectedPath}`) + } + catch (err) { + setError((err as Error).message) + } + finally { + setIsSaving(false) + } + } + + const isDirty = detail != null && draft !== detail.content + + if (!info) { + return ( +
+
+ {error || 'Loading file explorer...'} +
+
+ ) + } + + return ( +
+
+
+

File Explorer

+
+ + + +
+
+ +
+ + +
+ {!selectedPath && ( +
+ Select a file to load content on demand. +
+ )} + + {selectedPath && isLoadingFile && ( +
+ Loading file record for + {' '} + {selectedPath} + ... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {status && ( +
+ {status} +
+ )} + + {selectedPath && !isLoadingFile && !detail && ( +
+ File not found in static dump index. +
+ )} + + {selectedPath && !isLoadingFile && detail && ( +
+
{detail.path}
+
+ Size: + {' '} + {formatBytes(detail.size)} +
+ {!isStaticMode && ( +
+ + +
+ )} +