├── .prettierrc ├── .eslintignore ├── pnpm-workspace.yaml ├── playground ├── src │ ├── App.vue │ ├── main.ts │ └── style.css ├── devtools-test-2 │ ├── main.js │ └── index.html ├── public │ ├── iconfont.ttf │ └── vite.svg ├── devtools-test │ ├── vite.config.ts │ ├── index.html │ └── main.js ├── .gitignore ├── index.html ├── tsconfig.json ├── package.json └── vite.config.ts ├── renovate.json ├── .eslintrc ├── patak-tweet.png ├── .gitignore ├── test └── index.test.ts ├── .editorconfig ├── tsconfig.json ├── src ├── node │ └── views │ │ ├── vite.config.ts │ │ ├── app.js │ │ ├── Main.vue │ │ ├── utils.ts │ │ ├── FrameBox.vue │ │ └── composables.ts ├── functions │ ├── packages.ts │ └── assets.ts ├── types.d.ts ├── middlewares │ └── attach.ts ├── client.ts └── server.ts ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | -------------------------------------------------------------------------------- /playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-unjs"], 3 | "rules": {} 4 | } 5 | -------------------------------------------------------------------------------- /playground/devtools-test-2/main.js: -------------------------------------------------------------------------------- 1 | console.log('second devtools (devtools-test-2)') 2 | -------------------------------------------------------------------------------- /patak-tweet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pheno-agency/vite-plugin-devtools/HEAD/patak-tweet.png -------------------------------------------------------------------------------- /playground/public/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pheno-agency/vite-plugin-devtools/HEAD/playground/public/iconfont.ttf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | types 5 | .vscode 6 | .DS_Store 7 | .eslintcache 8 | *.log* 9 | *.env* 10 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './style.css' 3 | import App from './App.vue' 4 | 5 | const app = createApp(App) 6 | 7 | 8 | app.mount('#app') 9 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "vitest"; 2 | import {} from "../src"; 3 | 4 | describe("packageName", () => { 5 | it.todo("pass", () => { 6 | expect(true).toBe(true); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "skipLibCheck": true, 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /playground/devtools-test/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { getBase } from 'vite-plugin-devtools' 2 | import { defineConfig } from 'vite' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({base: getBase('devtools-test'), 6 | build: { 7 | target: 'esnext', 8 | sourcemap: 'inline', 9 | minify: false 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/node/views/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import vue from '@vitejs/plugin-vue' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | build: { 8 | sourcemap: true, 9 | lib: { 10 | entry: resolve(__dirname, "./app.js"), 11 | name: "view", 12 | fileName: "view", 13 | formats: ["es"], 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /playground/devtools-test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue DevTools 8 | 9 | 10 |
Vue Devtools
11 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /playground/devtools-test/main.js: -------------------------------------------------------------------------------- 1 | import { addClientFunction, clientRPC } from 'vite-plugin-devtools/client' 2 | 3 | console.log('first devtools (devtools-test)') 4 | 5 | addClientFunction('ping', () => { 6 | return 'response from client' 7 | }) 8 | 9 | console.log('packages in the project', await clientRPC.here()) 10 | console.log('packages in the project', await clientRPC.getPackages()) 11 | console.log('static assets', await clientRPC.staticAssets()) 12 | -------------------------------------------------------------------------------- /playground/devtools-test-2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nuxt DevTools 8 | 9 | 10 |
Nuxt DevTools
11 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/node/views/app.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from 'vue' 2 | import App from './Main.vue' 3 | 4 | function load() { 5 | const CONTAINER_ID = '__devtools-container__' 6 | const el = document.createElement('div') 7 | el.setAttribute('id', CONTAINER_ID) 8 | el.setAttribute('data-v-inspector-ignore', 'true') 9 | document.getElementsByTagName('body')[0].appendChild(el) 10 | createApp({ 11 | render: () => h(App), 12 | devtools: { 13 | hide: true, 14 | }, 15 | }).mount(el) 16 | } 17 | load() 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - run: corepack enable 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - run: pnpm lint 23 | - run: pnpm build 24 | - run: pnpm vitest --coverage 25 | - uses: codecov/codecov-action@v3 26 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite build devtools-test ; vite dev" 8 | }, 9 | "dependencies": { 10 | "@vueuse/core": "^10.1.2", 11 | "pinia": "^2.0.35", 12 | "vue": "^3.2.47", 13 | "vue-router": "^4.1.6" 14 | }, 15 | "devDependencies": { 16 | "@vitejs/plugin-vue": "^4.2.1", 17 | "serve": "^14.2.0", 18 | "typescript": "^5.0.4", 19 | "vite": "^4.3.5", 20 | "vite-plugin-inspect": "^0.7.25", 21 | "vite-plugin-devtools": "workspace:*" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/functions/packages.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from 'node:fs' 2 | import { resolve } from 'pathe' 3 | 4 | export async function getPackages(root: string) { 5 | // TODO: support monorepo workspace ? 6 | const pkgPath = resolve(root, 'package.json') 7 | const data: Record = JSON.parse(await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}')) 8 | const categorizedPackages: Record = {} 9 | const packages: Record = {} 10 | for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { 11 | if (!data[type]) 12 | continue 13 | categorizedPackages[type] = data[type] 14 | } 15 | for (const type in categorizedPackages) { 16 | for (const name in categorizedPackages[type]) { 17 | const version = categorizedPackages[type][name] 18 | packages[name] = version 19 | } 20 | } 21 | return { 22 | packages, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /playground/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | a { 19 | font-weight: 500; 20 | color: #646cff; 21 | text-decoration: inherit; 22 | } 23 | 24 | a:hover { 25 | color: #535bf2; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | /* display: flex; */ 31 | /* place-items: center; */ 32 | min-width: 320px; 33 | min-height: 100vh; 34 | } 35 | 36 | h1 { 37 | font-size: 3.2em; 38 | line-height: 1.1; 39 | } 40 | 41 | button { 42 | border-radius: 8px; 43 | border: 1px solid transparent; 44 | padding: 0.6em 1.2em; 45 | font-size: 1em; 46 | font-weight: 500; 47 | font-family: inherit; 48 | background-color: #1a1a1a; 49 | cursor: pointer; 50 | transition: border-color 0.25s; 51 | } 52 | 53 | button:hover { 54 | border-color: #646cff; 55 | } 56 | 57 | button:focus, 58 | button:focus-visible { 59 | outline: 4px auto -webkit-focus-ring-color; 60 | } 61 | 62 | .card { 63 | padding: 2em; 64 | } 65 | 66 | #app { 67 | max-width: 1280px; 68 | margin: 0 auto; 69 | padding: 2rem; 70 | text-align: center; 71 | } 72 | 73 | @media (prefers-color-scheme: light) { 74 | :root { 75 | color: #213547; 76 | background-color: #ffffff; 77 | } 78 | 79 | a:hover { 80 | color: #747bff; 81 | } 82 | 83 | button { 84 | background-color: #f9f9f9; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { BirpcReturn, BirpcGroupReturn } from 'birpc' 2 | 3 | export interface DefaultServerFunctions { 4 | staticAssets(): Promise 5 | getImageMeta(filepath: string): Promise 6 | getTextAssetContent(filepath: string, limit?: number): Promise 7 | getPackages(): Promise<{ packages: Record }> 8 | } 9 | 10 | export interface ServerFunctions { 11 | } 12 | 13 | export interface ClientFunctions { 14 | } 15 | 16 | export type ServerRPC = BirpcGroupReturn 17 | export type ServerFunction = (this: ServerRPC, ...args: any) => any 18 | export type ToServerFunction any> = (this: ServerRPC, ...args: Parameters) => ReturnType 19 | 20 | export type ClientRPC = BirpcReturn 21 | export type ClientFunction = (this: ClientRPC, ...args: any) => any 22 | export type ToClientFunction any> = (this: ClientRPC, ...args: Parameters) => ReturnType 23 | 24 | export type AssetType = 'image' | 'font' | 'video' | 'audio' | 'text' | 'json' | 'other' 25 | export interface AssetInfo { 26 | path: string 27 | type: AssetType 28 | publicPath: string 29 | filePath: string 30 | size: number 31 | mtime: number 32 | } 33 | export interface ImageMeta { 34 | width: number 35 | height: number 36 | orientation?: number 37 | type?: string 38 | mimeType?: string 39 | } 40 | 41 | declare global { 42 | export interface Window { 43 | popupIframes: Record Promise>; 44 | togglePanelVisible(name: string): void 45 | toggleViewMode(viewMode: "default" | "xs"): void 46 | getViewMode(): "default" | "xs" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/middlewares/attach.ts: -------------------------------------------------------------------------------- 1 | import type { BirpcGroup, ChannelOptions } from 'birpc'; 2 | import type { WebSocket } from 'ws' 3 | import { WebSocketServer } from 'ws' 4 | import type {ViteDevServer} from 'vite' 5 | import { parse, stringify } from 'flatted' 6 | import type { ClientFunctions, DefaultServerFunctions, ServerFunction, ServerFunctions, ServerRPC } from '../types'; 7 | 8 | export type Config = { serverFunctions: Record, serverRPC: ServerRPC | null} 9 | 10 | const wsClients = new Map>() 11 | 12 | export function attachWebSocket(rpc: BirpcGroup, iframeSrc: string, server: ViteDevServer) { 13 | if (!wsClients.has(iframeSrc)) { 14 | wsClients.set(iframeSrc, new Set()) 15 | } 16 | 17 | const route = '/__devtools__ws__/' + iframeSrc.split('/')[1] 18 | const wss = new WebSocketServer({ noServer: true }) 19 | 20 | server.httpServer?.on('upgrade', (request, socket, head) => { 21 | if (!request.url) 22 | return 23 | 24 | const { pathname } = new URL(request.url, 'http://localhost') 25 | if (pathname !== route) 26 | return 27 | 28 | wss.handleUpgrade(request, socket, head, (ws) => { 29 | wss.emit('connection', ws, request) 30 | 31 | wsClients.get(iframeSrc)!.add(ws) 32 | const channel: ChannelOptions = { 33 | post: d => ws.send(d), 34 | on: fn => ws.on('message', fn), 35 | serialize: stringify, 36 | deserialize: parse, 37 | } 38 | rpc.updateChannels((c) => { 39 | c.push(channel) 40 | }) 41 | ws.on('close', () => { 42 | wsClients.get(iframeSrc)!.delete(ws) 43 | rpc.updateChannels((c) => { 44 | const index = c.indexOf(channel) 45 | if (index >= 0) 46 | c.splice(index, 1) 47 | }) 48 | }) 49 | }) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-devtools", 3 | "version": "0.0.0", 4 | "description": "", 5 | "repository": "pheno-agency/vite-plugin-devtools", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/server.d.ts", 12 | "import": "./dist/server.mjs", 13 | "require": "./dist/server.cjs" 14 | }, 15 | "./dist/client": { 16 | "types": "./dist/client.d.ts", 17 | "import": "./dist/client.mjs" 18 | }, 19 | "./client": { 20 | "types": "./dist/client.d.ts", 21 | "import": "./dist/client.mjs" 22 | }, 23 | "./dist/server": { 24 | "types": "./dist/server.d.ts", 25 | "import": "./dist/server.mjs" 26 | }, 27 | "./server": { 28 | "types": "./dist/server.d.ts", 29 | "import": "./dist/server.mjs" 30 | } 31 | }, 32 | "main": "./dist/server.mjs", 33 | "module": "./dist/server.mjs", 34 | "types": "./dist/server.d.ts", 35 | "files": [ 36 | "dist", 37 | "src" 38 | ], 39 | "scripts": { 40 | "build": "vite build ./src/node/views ; unbuild", 41 | "dev": "vitest dev", 42 | "lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src test", 43 | "lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src test -w", 44 | "prepack": "pnpm run build", 45 | "release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", 46 | "test": "pnpm lint && vitest run --coverage" 47 | }, 48 | "peerDependencies": { 49 | "vite": "^3.0.0-0 || ^4.0.0-0" 50 | }, 51 | "dependencies": { 52 | "@types/ws": "^8.5.4", 53 | "birpc": "^0.2.11", 54 | "fast-glob": "^3.2.12", 55 | "flatted": "^3.2.7", 56 | "image-meta": "^0.1.1", 57 | "nanoid": "^4.0.2", 58 | "pathe": "^1.1.1", 59 | "sirv": "^2.0.3", 60 | "solid-js": "^1.7.7", 61 | "splitpanes": "^3.1.5", 62 | "tinyws": "^0.1.0", 63 | "vite-dev-rpc": "^0.1.2", 64 | "vite-hot-client": "^0.2.1", 65 | "ws": "^8.13.0" 66 | }, 67 | "devDependencies": { 68 | "@vitejs/plugin-vue": "^4.2.1", 69 | "@vitest/coverage-c8": "^0.30.1", 70 | "changelogen": "^0.5.3", 71 | "esbuild": "^0.18.10", 72 | "eslint": "^8.39.0", 73 | "eslint-config-unjs": "^0.1.0", 74 | "prettier": "^2.8.8", 75 | "typescript": "^5.0.4", 76 | "unbuild": "^1.2.1", 77 | "vite": "^4.3.5", 78 | "vitest": "^0.30.1", 79 | "vue": "^3.2.47" 80 | }, 81 | "packageManager": "pnpm@7.32.2" 82 | } 83 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import createDevtools from "vite-plugin-devtools"; 4 | 5 | declare module "vite-plugin-devtools" { 6 | export interface ServerFunctions { 7 | here(): string; 8 | } 9 | export interface ClientFunctions { 10 | ping(): string; 11 | } 12 | } 13 | 14 | const { addServerFunction, plugin, serverRPC } = createDevtools("devtools-test", { 15 | icon: ` 16 | 17 | 18 | 19 | 20 | 21 | `, 22 | clientDir: "./devtools-test/dist/", 23 | }); 24 | 25 | addServerFunction("here", function () { 26 | console.log(serverRPC === this) 27 | return "here"; 28 | }); 29 | 30 | setInterval(async () => { 31 | console.log('pinging the client, response:', await serverRPC.ping()); 32 | }, 3000); 33 | 34 | // https://vitejs.dev/config/ 35 | export default defineConfig({ 36 | plugins: [ 37 | // VueDevtools(), 38 | plugin, 39 | vue(), 40 | // multiple devtools can be used at the same time 41 | createDevtools("devtools-test-2", { 42 | icon: ` 43 | 44 | 45 | 46 | `, 47 | clientDir: "./devtools-test-2", 48 | }).plugin, 49 | ], 50 | }); 51 | -------------------------------------------------------------------------------- /src/functions/assets.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import p from 'node:path' 3 | import fg from 'fast-glob' 4 | import { join, resolve } from 'pathe' 5 | import type { ResolvedConfig } from 'vite' 6 | import { imageMeta } from 'image-meta' 7 | import type { AssetInfo, AssetType, ImageMeta } from '../types' 8 | 9 | const _imageMetaCache = new Map() 10 | 11 | function guessType(path: string): AssetType { 12 | if (/\.(png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?)$/i.test(path)) 13 | return 'image' 14 | if (/\.(mp4|webm|ogv|mov|avi|flv|wmv|mpg|mpeg|mkv|3gp|3g2|ts|mts|m2ts|vob|ogm|ogx|rm|rmvb|asf|amv|divx|m4v|svi|viv|f4v|f4p|f4a|f4b)$/i.test(path)) 15 | return 'video' 16 | if (/\.(mp3|wav|ogg|flac|aac|wma|alac|ape|ac3|dts|tta|opus|amr|aiff|au|mid|midi|ra|rm|wv|weba|dss|spx|vox|tak|dsf|dff|dsd|cda)$/i.test(path)) 17 | return 'audio' 18 | if (/\.(woff2?|eot|ttf|otf|ttc|pfa|pfb|pfm|afm)/i.test(path)) 19 | return 'font' 20 | if (/\.(json[5c]?|te?xt|[mc]?[jt]sx?|md[cx]?|markdown)/i.test(path)) 21 | return 'text' 22 | return 'other' 23 | } 24 | 25 | export async function getStaticAssets(config: ResolvedConfig): Promise { 26 | const dir = resolve(config.root) 27 | const baseURL = config.base 28 | 29 | const files = await fg([ 30 | // image 31 | '**/*.(png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff)', 32 | // video 33 | '**/*.(mp4|webm|ogv|mov|avi|flv|wmv|mpg|mpeg|mkv|3gp|3g2|m2ts|vob|ogm|ogx|rm|rmvb|asf|amv|divx|m4v|svi|viv|f4v|f4p|f4a|f4b)', 34 | // audio 35 | '**/*.(mp3|wav|ogg|flac|aac|wma|alac|ape|ac3|dts|tta|opus|amr|aiff|au|mid|midi|ra|rm|wv|weba|dss|spx|vox|tak|dsf|dff|dsd|cda)', 36 | // font 37 | '**/*.(woff2?|eot|ttf|otf|ttc|pfa|pfb|pfm|afm)', 38 | // text 39 | '**/*.(json[5c]?|te?xt|[mc]?[jt]sx?|md[cx]?|markdown)', 40 | ], { 41 | cwd: dir, 42 | onlyFiles: true, 43 | ignore: ['**/node_modules/**', '**/dist/**'], 44 | }) 45 | 46 | return await Promise.all(files.map(async (path) => { 47 | const filePath = resolve(dir, path) 48 | const stat = await fs.lstat(filePath) 49 | const publicDirname = p.relative(config.root, config.publicDir) 50 | const normalizedPath = publicDirname === p.basename(p.dirname(path)) ? path.replace(publicDirname, '') : path 51 | return { 52 | path: normalizedPath, 53 | filePath, 54 | publicPath: join(baseURL, normalizedPath), 55 | type: guessType(path), 56 | size: stat.size, 57 | mtime: stat.mtimeMs, 58 | } 59 | })) 60 | } 61 | 62 | export async function getImageMeta(filepath: string) { 63 | if (_imageMetaCache.has(filepath)) 64 | return _imageMetaCache.get(filepath) 65 | try { 66 | const meta = imageMeta(await fs.readFile(filepath)) as ImageMeta 67 | _imageMetaCache.set(filepath, meta) 68 | return meta 69 | } 70 | catch (e) { 71 | _imageMetaCache.set(filepath, undefined) 72 | console.error(e) 73 | return undefined 74 | } 75 | } 76 | 77 | export async function getTextAssetContent(filepath: string, limit = 300) { 78 | try { 79 | const content = await fs.readFile(filepath, 'utf-8') 80 | return content.slice(0, limit) 81 | } 82 | catch (e) { 83 | console.error(e) 84 | return undefined 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { parse, stringify } from "flatted"; 2 | import { 3 | ClientFunction, 4 | ClientFunctions, 5 | ClientRPC, 6 | DefaultServerFunctions, 7 | ServerFunctions, 8 | ToClientFunction, 9 | } from "./types"; 10 | export type * from "./types"; 11 | import { createBirpc } from "birpc"; 12 | 13 | const clientFunctions = {} as Record; 14 | 15 | const isInIframe = (function inIframe() { 16 | try { 17 | return window.self !== window.top; 18 | } catch (e) { 19 | return true; 20 | } 21 | })(); 22 | 23 | const PORT = location.port; 24 | const HOST = [location.hostname, PORT].filter(Boolean).join(":"); 25 | const ROUTE = "/__devtools__ws__/" + location.pathname.split("/")[1]; 26 | const ENTRY_URL = 27 | `${location.protocol === "https:" ? "wss:" : "ws:"}//${HOST}` + ROUTE; 28 | const RECONNECT_INTERVAL = 2000; 29 | 30 | let connectPromise = isInIframe ? connectWS() : null; 31 | let onMessage: Function = () => {}; 32 | 33 | export const clientRPC: ClientRPC = createBirpc< 34 | DefaultServerFunctions & ServerFunctions 35 | >(clientFunctions, { 36 | post: async (d) => { 37 | (await connectPromise!).send(d); 38 | }, 39 | on: (fn) => { 40 | onMessage = fn; 41 | }, 42 | // these are required when using WebSocket 43 | serialize: stringify, 44 | deserialize: parse, 45 | }); 46 | 47 | async function connectWS() { 48 | const ws = new WebSocket(ENTRY_URL); 49 | ws.addEventListener("message", (e) => onMessage(String(e.data))); 50 | ws.addEventListener("error", (e) => { 51 | console.error(e); 52 | }); 53 | ws.addEventListener("close", () => { 54 | setTimeout(async () => { 55 | connectPromise = connectWS(); 56 | }, RECONNECT_INTERVAL); 57 | }); 58 | if (ws.readyState !== WebSocket.OPEN) 59 | await new Promise((resolve) => ws.addEventListener("open", resolve)); 60 | return ws; 61 | } 62 | 63 | export function addClientFunction( 64 | name: T, 65 | func: ToClientFunction 66 | ): void; 67 | export function addClientFunction(func: ClientFunction): void; 68 | export function addClientFunction( 69 | nameOrFunc: string | ClientFunction, 70 | func?: ToClientFunction 71 | ) { 72 | func = typeof nameOrFunc === "function" ? nameOrFunc : func; 73 | const name = typeof nameOrFunc === "string" ? nameOrFunc : func?.name; 74 | if (!func) { 75 | throw new Error("Please specify a client function"); 76 | } 77 | if (!name) { 78 | throw new Error("Please specify a name for your client function"); 79 | } 80 | 81 | clientFunctions[name] = func.bind(clientRPC); 82 | } 83 | 84 | export function togglePopup(name: string) { 85 | return window.popupIframes[name]?.(); 86 | } 87 | 88 | export function getTheme() { 89 | return JSON.parse(localStorage.getItem("__devtools-frame-state__")!).theme; 90 | } 91 | 92 | export function toggleTheme(theme: "dark" | "auto" | "light") { 93 | localStorage.setItem("__devtools-frame-state__", JSON.stringify({ 94 | ...JSON.parse(localStorage.getItem("__devtools-frame-state__")!), 95 | theme, 96 | })); 97 | } 98 | 99 | export function onThemeChange(cb: (theme: string) => any) { 100 | const listener = (e: StorageEvent) => { 101 | if ( 102 | e.key === "__devtools-frame-state__" && 103 | JSON.parse(e.newValue!).theme !== JSON.parse(e.oldValue!).theme 104 | ) 105 | cb(JSON.parse(e.newValue!).theme); 106 | }; 107 | window.addEventListener("storage", listener); 108 | return () => window.removeEventListener("storage", listener); 109 | } 110 | 111 | export const togglePanelVisible = window.togglePanelVisible 112 | export const toggleViewMode = window.toggleViewMode 113 | export const getViewMode = window.getViewMode 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vite-plugin-devtools 2 | 3 | ![vite-plugin-devtools](patak-tweet.png) 4 | 5 | ## Description 6 | 7 | vite-plugin-devtools is a framework-agnostic devtools builder designed for any tool or library based on Vite. It serves as a shared foundation for other devtools plugins such as [vite-plugin-vue-devtools](https://github.com/webfansplz/vite-plugin-vue-devtools) and [nuxt-devtools](https://github.com/nuxt/devtools). 8 | 9 | Key Features: 10 | 11 | - Extensive API 12 | - Multiple devtools options 13 | - Simple and lightweight 14 | - Full control over the client and server 15 | 16 | ## Usage 17 | 18 | The following code demonstrates how to use vite-plugin-devtools: 19 | 20 | ```typescript 21 | // server (vite.config.js or plugin entry) 22 | import createDevtools from "vite-plugin-devtools"; 23 | 24 | const { addServerFunction, plugin, serverRPC } = createDevtools("devtools-test", { 25 | icon: `/* svg string */`, 26 | clientDir: "./test/dist/", 27 | }); 28 | 29 | // client (in `clientDir`) 30 | import { addClientFunction } from 'vite-plugin-devtools/client' 31 | 32 | addClientFunction('ping', () => { 33 | return 'response from client' 34 | }) 35 | ``` 36 | 37 | ## API 38 | 39 | ### createDevtools 40 | 41 | The `createDevtools` function is used to initialize the devtools. 42 | 43 | ```typescript 44 | type Options = { 45 | icon: string; 46 | clientDir: string; 47 | }; 48 | 49 | declare function createDevtools(name: string, options: Options): { 50 | serverRPC: birpc.BirpcGroupReturn; 51 | addServerFunction: AddServerFunction; 52 | plugin: PluginOption; 53 | }; 54 | ``` 55 | 56 | #### addServerFunction 57 | 58 | The `addServerFunction` function allows adding server functions. 59 | 60 | ```typescript 61 | addServerFunction("here", function() { 62 | return "here"; 63 | }); 64 | ``` 65 | 66 | #### plugin 67 | 68 | The `plugin` option can be passed to Vite to integrate the devtools. 69 | 70 | #### serverRPC 71 | 72 | The `serverRPC` object is used to call client functions and is bound to the server functions. 73 | 74 | ```typescript 75 | addServerFunction("here", function() { 76 | console.log(serverRPC === this); 77 | return "here"; 78 | }); 79 | ``` 80 | 81 | ### Client 82 | 83 | ```typescript 84 | declare const clientRPC: ClientRPC; 85 | declare function addClientFunction(name: T, func: ToClientFunction): void; 86 | declare function addClientFunction(func: ClientFunction): void; 87 | declare function changePosition(position: 'bottom' | 'top' | 'left' | 'right'): void; 88 | ``` 89 | 90 | #### addClientFunction 91 | 92 | The `addClientFunction` function is used to add a client function that can be called by the server using RPC. 93 | 94 | ```typescript 95 | import { addClientFunction } from 'vite-plugin-devtools/client' 96 | 97 | addClientFunction('ping', () => { 98 | return 'response from client' 99 | }) 100 | ``` 101 | 102 | Server example: 103 | 104 | ```typescript 105 | setInterval(async () => { 106 | console.log('pinging the client, response:', await serverRPC.ping()); 107 | }, 3000); 108 | ``` 109 | 110 | #### clientRPC 111 | 112 | The `clientRPC` object allows calling functions defined by the server using `addServerFunction`. 113 | 114 | #### changePosition 115 | 116 | The `changePosition` function is used to modify the position of the devtools bar and associated iframes. It affects the positions of other devtools as well. 117 | 118 | ## TypeScript 119 | 120 | To enhance autocompletion and type safety for your functions, `serverRPC`, `clientRPC`, 121 | 122 | and the `this` keyword, you can use type guarding by extending the `ServerFunctions` and `ClientFunctions` interfaces. 123 | 124 | ```typescript 125 | declare module "vite-plugin-devtools" { 126 | export interface ServerFunctions { 127 | here(): string; 128 | } 129 | export interface ClientFunctions { 130 | ping(): string; 131 | } 132 | } 133 | ``` 134 | 135 | This allows for better development experience with autocompletion and type checking. 136 | 137 | ## Utilities 138 | 139 | Common utilities that may be used by devtools plugins. 140 | 141 | ```ts 142 | interface DefaultServerFunctions { 143 | staticAssets(): Promise 144 | getImageMeta(filepath: string): Promise 145 | getTextAssetContent(filepath: string, limit?: number): Promise 146 | getPackages(): Promise<{ packages: Record }> 147 | } 148 | ``` 149 | These are all available by default in `clientRPC`. 150 | 151 | -------------------------------------------------------------------------------- /src/node/views/Main.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 86 | 87 | 194 | -------------------------------------------------------------------------------- /src/node/views/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, getCurrentScope, onScopeDispose, ref, watch, watchEffect, 3 | } from 'vue' 4 | import type { Ref } from 'vue' 5 | 6 | export function tryOnScopeDispose(fn: () => void) { 7 | const scope = getCurrentScope() 8 | if (scope) 9 | onScopeDispose(fn) 10 | } 11 | 12 | // ---- storage ---- 13 | export function useObjectStorage(key: string, initial: T, readonly = false): Ref { 14 | const raw = localStorage.getItem(key) 15 | const data = ref(raw ? JSON.parse(raw) : initial) 16 | 17 | for (const key in initial) { 18 | if (data.value[key] === undefined) 19 | data.value[key] = initial[key] 20 | } 21 | 22 | let updating = false 23 | let wrote = '' 24 | 25 | if (!readonly) { 26 | watch(data, (value) => { 27 | if (updating) 28 | return 29 | wrote = JSON.stringify(value) 30 | localStorage.setItem(key, wrote) 31 | }, { deep: true, flush: 'post' }) 32 | } 33 | 34 | const storageChanged = (newValue: string) => { 35 | updating = true 36 | data.value = JSON.parse(newValue) 37 | updating = false 38 | } 39 | 40 | useWindowEventListener('storage', (e: StorageEvent) => { 41 | if (e.key === key && e.newValue && e.newValue !== wrote) 42 | storageChanged(e.newValue) 43 | }) 44 | 45 | // @ts-expect-error custom event 46 | useWindowEventListener('vueuse-storage', (e: CustomEvent) => { 47 | const event = e.detail as StorageEvent 48 | if (event.key === key && event.newValue && event.newValue !== wrote) 49 | storageChanged(event.newValue) 50 | }) 51 | 52 | return data 53 | } 54 | 55 | export function useStorage(key: string, initial: T, readonly = false) { 56 | const raw = localStorage.getItem(key) 57 | const data = ref(raw || initial) 58 | 59 | let updating = false 60 | let wrote = '' 61 | 62 | if (!readonly) { 63 | watch(data, (value) => { 64 | if (updating) 65 | return 66 | wrote = `${value}` 67 | localStorage.setItem(key, wrote) 68 | }, { deep: true, flush: 'post' }) 69 | } 70 | 71 | const storageChanged = (newValue: string) => { 72 | updating = true 73 | data.value = newValue 74 | updating = false 75 | } 76 | 77 | useWindowEventListener('storage', (e: StorageEvent) => { 78 | if (e.key === key && e.newValue && e.newValue !== wrote) 79 | storageChanged(e.newValue) 80 | }) 81 | 82 | // @ts-expect-error custom event 83 | useWindowEventListener('vueuse-storage', (e: CustomEvent) => { 84 | const event = e.detail as StorageEvent 85 | if (event.key === key && event.newValue && event.newValue !== wrote) 86 | storageChanged(event.newValue) 87 | }) 88 | 89 | return data 90 | } 91 | 92 | // ---- index ---- 93 | export const checkIsSafari = () => navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') 94 | export function clamp(value: number, min: number, max: number) { 95 | return Math.min(Math.max(value, min), max) 96 | } 97 | 98 | // ---- event ---- 99 | export function useEventListener( 100 | target: EventTarget, 101 | type: keyof WindowEventHandlersEventMap, 102 | listener: EventListenerOrEventListenerObject, 103 | options?: boolean | AddEventListenerOptions, 104 | ) { 105 | target.addEventListener(type, listener, options) 106 | tryOnScopeDispose(() => target.removeEventListener(type, listener, options)) 107 | } 108 | 109 | export function useWindowEventListener( 110 | type: K, 111 | listener: (this: Window, ev: WindowEventMap[K]) => any, 112 | options?: boolean | AddEventListenerOptions, 113 | ) { 114 | useEventListener(window, type as keyof WindowEventHandlersEventMap, listener as EventListener, options) 115 | } 116 | 117 | // ---- screen ---- 118 | const topVarName = '--vite-plugin-devtools-safe-area-top' 119 | const rightVarName = '--vite-plugin-devtools-devtools-safe-area-right' 120 | const bottomVarName = '--vite-plugin-devtools-safe-area-bottom' 121 | const leftVarName = '--vite-plugin-devtools-safe-area-left' 122 | 123 | /** 124 | * Reactive `env(safe-area-inset-*)` 125 | * 126 | * @see https://vueuse.org/useScreenSafeArea 127 | */ 128 | export function useScreenSafeArea() { 129 | const top = ref(0) 130 | const right = ref(0) 131 | const bottom = ref(0) 132 | const left = ref(0) 133 | 134 | document.documentElement.style.setProperty(topVarName, 'env(safe-area-inset-top, 0px)') 135 | document.documentElement.style.setProperty(rightVarName, 'env(safe-area-inset-right, 0px)') 136 | document.documentElement.style.setProperty(bottomVarName, 'env(safe-area-inset-bottom, 0px)') 137 | document.documentElement.style.setProperty(leftVarName, 'env(safe-area-inset-left, 0px)') 138 | 139 | update() 140 | useWindowEventListener('resize', update) 141 | 142 | function getValue(position: string) { 143 | return Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue(position)) || 0 144 | } 145 | 146 | function update() { 147 | top.value = getValue(topVarName) 148 | right.value = getValue(rightVarName) 149 | bottom.value = getValue(bottomVarName) 150 | left.value = getValue(leftVarName) 151 | } 152 | 153 | return { 154 | top, 155 | right, 156 | bottom, 157 | left, 158 | update, 159 | } 160 | } 161 | 162 | 163 | 164 | /** 165 | * Reactive Media Query. 166 | * 167 | * @see https://vueuse.org/useMediaQuery 168 | * @param query 169 | * @param options 170 | */ 171 | export function useMediaQuery(query: string) { 172 | const isSupported = () => window && 'matchMedia' in window && typeof window.matchMedia === 'function' 173 | 174 | let mediaQuery: MediaQueryList | undefined 175 | const matches = ref(false) 176 | 177 | const cleanup = () => { 178 | if (!mediaQuery) 179 | return 180 | if ('removeEventListener' in mediaQuery) 181 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 182 | mediaQuery.removeEventListener('change', update) 183 | else 184 | // @ts-expect-error deprecated API 185 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 186 | mediaQuery.removeListener(update) 187 | } 188 | 189 | const update = () => { 190 | if (!isSupported) 191 | return 192 | 193 | cleanup() 194 | 195 | mediaQuery = window!.matchMedia(ref(query).value) 196 | matches.value = !!mediaQuery?.matches 197 | 198 | if (!mediaQuery) 199 | return 200 | 201 | if ('addEventListener' in mediaQuery) 202 | mediaQuery.addEventListener('change', update) 203 | else 204 | // @ts-expect-error deprecated API 205 | mediaQuery.addListener(update) 206 | } 207 | watchEffect(update) 208 | 209 | tryOnScopeDispose(() => cleanup()) 210 | 211 | return matches 212 | } 213 | /** 214 | * Reactive prefers-color-scheme media query. 215 | * 216 | * @see https://vueuse.org/usePreferredColorScheme 217 | * @param [options] 218 | */ 219 | export function usePreferredColorScheme() { 220 | const isDark = useMediaQuery('(prefers-color-scheme: dark)') 221 | 222 | return computed(() => isDark.value ? 'dark' : 'light') 223 | } 224 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import { normalizePath } from "vite"; 4 | import type { 5 | PluginOption, 6 | HtmlTagDescriptor, 7 | ViteDevServer, 8 | Plugin, 9 | } from "vite"; 10 | import { createBirpcGroup } from "birpc"; 11 | import sirv from "sirv"; 12 | import type { 13 | ClientFunctions, 14 | DefaultServerFunctions, 15 | ServerFunction, 16 | ServerFunctions, 17 | ToServerFunction, 18 | } from "./types"; 19 | import { attachWebSocket, Config } from "./middlewares/attach"; 20 | import { getPackages } from "./functions/packages"; 21 | import { 22 | getImageMeta, 23 | getStaticAssets, 24 | getTextAssetContent, 25 | } from "./functions/assets"; 26 | export type * from "./types"; 27 | 28 | type Options = { 29 | icon: string; 30 | clientDir: string; 31 | inspector?: boolean; 32 | onIframe?: string; 33 | 34 | /** 35 | * append an import to the module id ending with `appendTo` instead of adding a 36 | * script into body useful for projects that do not use html file as an entry 37 | * 38 | * WARNING: only set this if you know exactly what it does. 39 | */ 40 | appendTo?: string | RegExp; 41 | }; 42 | 43 | function getDevtoolsPath() { 44 | const pluginPath = normalizePath( 45 | path.dirname(fileURLToPath(import.meta.url)) 46 | ); 47 | return pluginPath 48 | .replace(/\/src/, "/src/node") 49 | .replace(/\/dist$/, "/src/node"); 50 | } 51 | 52 | export type KeyOf = keyof T extends any ? keyof T : keyof T; 53 | 54 | function addServerFunctionToConfig( 55 | config: Config, 56 | name: T, 57 | func: ToServerFunction 58 | ): void; 59 | function addServerFunctionToConfig(config: Config, func: ServerFunction): void; 60 | function addServerFunctionToConfig( 61 | config: Config, 62 | nameOrFunc: T | ServerFunction, 63 | func?: ToServerFunction 64 | ): void { 65 | func = typeof nameOrFunc === "function" ? nameOrFunc : func; 66 | const name = typeof nameOrFunc === "string" ? nameOrFunc : func?.name; 67 | if (!func) { 68 | throw new Error("Please specify a server function"); 69 | } 70 | if (!name) { 71 | throw new Error("Please specify a name for your server function"); 72 | } 73 | // @ts-ignore 74 | config.serverFunctions[name] = func.bind(config.serverRPC); 75 | } 76 | 77 | interface AddServerFunction { 78 | (name: T, func: ToServerFunction): void; 79 | (func: ServerFunction): void; 80 | } 81 | 82 | // useful for detecting only one time `views/dist/view.js` run 83 | let firstPlugin: string; 84 | let appendTo: string | RegExp | undefined 85 | 86 | export function getBase(name: string) { 87 | return `/__${name}_devtools__/`; 88 | } 89 | 90 | export default function createDevtools< 91 | T extends keyof ServerFunctions = keyof ServerFunctions 92 | >(name: string, options: Options) { 93 | appendTo = options.appendTo 94 | let server: ViteDevServer; 95 | const config: Config = { 96 | serverFunctions: { 97 | staticAssets: () => getStaticAssets(server.config), 98 | getImageMeta, 99 | getTextAssetContent, 100 | getPackages: () => getPackages(server.config.root), 101 | } as DefaultServerFunctions as any, 102 | serverRPC: null, 103 | }; 104 | 105 | const rpc = createBirpcGroup< 106 | ClientFunctions, 107 | DefaultServerFunctions & ServerFunctions 108 | >(config.serverFunctions as any, [], {}); 109 | config.serverRPC = rpc.broadcast; 110 | 111 | const devtoolsPath = getDevtoolsPath(); 112 | let iframeSrc: string; 113 | 114 | return { 115 | serverRPC: config.serverRPC, 116 | addServerFunction: addServerFunctionToConfig.bind( 117 | null, 118 | config 119 | ) as unknown as AddServerFunction, 120 | plugin: { 121 | name, 122 | enforce: "pre", 123 | apply: "serve", 124 | configureServer(_server) { 125 | server = _server; 126 | const base = server.config.base || "/"; 127 | iframeSrc = `${base}__${name}_devtools__`; 128 | server.middlewares.use( 129 | iframeSrc, 130 | sirv(options.clientDir, { single: true, dev: true }) 131 | ); 132 | attachWebSocket(rpc, iframeSrc, server); 133 | }, 134 | async resolveId(importee: string, importer) { 135 | if (importee.startsWith(`virtual:devtools:${name}:`)) { 136 | const resolvedAppPath = importee.replace( 137 | `virtual:devtools:${name}:`, 138 | `${devtoolsPath}/` 139 | ); 140 | return { id: resolvedAppPath, external: "absolute" }; 141 | } 142 | }, 143 | transform(code, id) { 144 | if (!appendTo) 145 | return 146 | 147 | let imports: string | undefined 148 | if (!firstPlugin) { 149 | firstPlugin = name; 150 | imports = ` 151 | import "virtual:devtools:${name}:views/dist/view.js" 152 | import "virtual:devtools:${name}:views/dist/style.css" 153 | ` 154 | } 155 | 156 | const [filename] = id.split('?', 2) 157 | if ((typeof appendTo === 'string' && filename.endsWith(appendTo)) 158 | || (appendTo instanceof RegExp && appendTo.test(filename))) 159 | return { code: `${code}\n 160 | globalThis.__devtools ??= [] 161 | globalThis.__devtools.push({ 162 | name: '${name}', 163 | iframeSrc: '${iframeSrc}', 164 | icon: \`${options.icon}\`, 165 | inspector: ${!!options?.inspector}, 166 | onIframe: '${options.onIframe}' 167 | }) 168 | ${imports}` } 169 | }, 170 | transformIndexHtml(html) { 171 | if (appendTo) { 172 | return 173 | } 174 | if (!firstPlugin) { 175 | firstPlugin = name; 176 | } 177 | const tags: HtmlTagDescriptor[] = [ 178 | { 179 | tag: "script", 180 | injectTo: "head-prepend", 181 | children: ` 182 | globalThis.__devtools ??= [] 183 | globalThis.__devtools.push({ 184 | name: '${name}', 185 | iframeSrc: '${iframeSrc}', 186 | icon: \`${options.icon}\`, 187 | inspector: ${!!options?.inspector}, 188 | onIframe: '${options.onIframe}' 189 | }) 190 | `, 191 | attrs: { 192 | type: "module", 193 | }, 194 | }, 195 | ...(firstPlugin === name 196 | ? ([ 197 | { 198 | tag: "script", 199 | injectTo: "head", 200 | attrs: { 201 | type: "module", 202 | src: `/@id/virtual:devtools:${name}:views/dist/view.js`, 203 | }, 204 | }, 205 | { 206 | tag: "link", 207 | injectTo: "head", 208 | attrs: { 209 | rel: "stylesheet", 210 | href: `/@id/virtual:devtools:${name}:views/dist/style.css`, 211 | }, 212 | }, 213 | ] satisfies HtmlTagDescriptor[]) 214 | : []), 215 | ]; 216 | return { 217 | html, 218 | tags, 219 | }; 220 | }, 221 | } satisfies Plugin, 222 | }; 223 | } 224 | -------------------------------------------------------------------------------- /src/node/views/FrameBox.vue: -------------------------------------------------------------------------------- 1 | 123 | 124 | 182 | 183 | 232 | -------------------------------------------------------------------------------- /src/node/views/composables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | onMounted, 4 | reactive, 5 | ref, 6 | shallowRef, 7 | watchEffect, 8 | } from "vue"; 9 | import type { CSSProperties, Ref } from "vue"; 10 | import { 11 | clamp, 12 | useObjectStorage, 13 | useScreenSafeArea, 14 | useWindowEventListener, 15 | } from "./utils"; 16 | 17 | interface DevToolsFrameState { 18 | width: number; 19 | height: number; 20 | top: number; 21 | left: number; 22 | open: string | null; // iframeSrc 23 | route: string; 24 | position: string; 25 | isFirstVisit: boolean; 26 | closeOnOutsideClick: boolean; 27 | viewMode: "default" | "xs"; 28 | theme: "dark" | "auto" | "light"; 29 | } 30 | 31 | // ---- state ---- 32 | export const PANEL_PADDING = 10; 33 | export const PANEL_MIN = 20; 34 | export const PANEL_MAX = 100; 35 | 36 | export const popupWindow = shallowRef(null); 37 | 38 | export const state = useObjectStorage( 39 | "__devtools-frame-state__", 40 | { 41 | width: 80, 42 | height: 60, 43 | top: 0, 44 | left: 50, 45 | open: null, 46 | route: "/", 47 | position: "bottom", 48 | isFirstVisit: true, 49 | closeOnOutsideClick: false, 50 | viewMode: "default", 51 | theme: "auto", 52 | } 53 | ); 54 | 55 | // color-scheme 56 | export const useColorScheme = () => computed(() => state.value.theme); 57 | 58 | // ---- useIframe ---- 59 | export function useIframe(name: string, clientUrl: string) { 60 | const iframe = ref(); 61 | function getIframe() { 62 | if (iframe.value) return iframe.value; 63 | iframe.value = document.createElement("iframe"); 64 | iframe.value.id = name; 65 | iframe.value.src = clientUrl; 66 | iframe.value.setAttribute("data-v-inspector-ignore", "true"); 67 | return iframe.value; 68 | } 69 | 70 | return { 71 | getIframe, 72 | iframe, 73 | }; 74 | } 75 | 76 | // ---- useInspector ---- 77 | export function useInspector() { 78 | const inspectorEnabled = ref(false); 79 | const inspectorLoaded = ref(false); 80 | 81 | const enable = () => { 82 | window.__VUE_INSPECTOR__?.enable(); 83 | inspectorEnabled.value = true; 84 | }; 85 | 86 | const disable = () => { 87 | window.__VUE_INSPECTOR__?.disable(); 88 | inspectorEnabled.value = false; 89 | }; 90 | 91 | const setupInspector = () => { 92 | const componentInspector = window.__VUE_INSPECTOR__; 93 | if (componentInspector) { 94 | const _openInEditor = componentInspector.openInEditor; 95 | componentInspector.openInEditor = async (...params: any[]) => { 96 | disable(); 97 | _openInEditor(...params); 98 | }; 99 | } 100 | }; 101 | 102 | const waitForInspectorInit = () => { 103 | const timer = setInterval(() => { 104 | if (window.__VUE_INSPECTOR__) { 105 | clearInterval(timer); 106 | inspectorLoaded.value = true; 107 | setupInspector(); 108 | } 109 | }, 30); 110 | }; 111 | 112 | useWindowEventListener("keydown", (e: KeyboardEvent) => { 113 | if (!inspectorEnabled.value || !inspectorLoaded.value) return; 114 | if (e.key === "Escape") disable(); 115 | }); 116 | 117 | waitForInspectorInit(); 118 | 119 | return { 120 | toggleInspector() { 121 | if (!inspectorLoaded.value) return; 122 | inspectorEnabled.value ? disable() : enable(); 123 | }, 124 | inspectorEnabled, 125 | enableInspector: enable, 126 | disableInspector: disable, 127 | setupInspector, 128 | inspectorLoaded, 129 | }; 130 | } 131 | 132 | // ---- usePanelVisible ---- 133 | export function usePanelVisible() { 134 | const visible = computed({ 135 | get() { 136 | return state.value.open; 137 | }, 138 | set(value) { 139 | state.value.open = value; 140 | }, 141 | }); 142 | 143 | const toggleVisible = (name: string) => { 144 | visible.value = visible.value === name ? null : name; 145 | }; 146 | 147 | const toggleViewMode = (viewMode: "default" | "xs") =>{ 148 | state.value.viewMode = viewMode ?? 'default' 149 | } 150 | 151 | const closePanel = () => { 152 | if (!visible.value) return; 153 | visible.value = null; 154 | if (popupWindow.value) { 155 | try { 156 | popupWindow.value.close(); 157 | } catch {} 158 | popupWindow.value = null; 159 | } 160 | }; 161 | 162 | return { 163 | panelVisible: visible, 164 | togglePanelVisible: toggleVisible, 165 | toggleViewMode, 166 | getViewMode: () => state.value.viewMode, 167 | closePanel, 168 | }; 169 | } 170 | 171 | /* declare global { 172 | var popupIframes: Record Promise>; 173 | } */ 174 | window.popupIframes ??= {}; 175 | 176 | // ---- usePipMode ---- 177 | export function usePiPMode( 178 | name: string, 179 | iframeGetter: () => HTMLIFrameElement | undefined 180 | ) { 181 | // Experimental: Picture-in-Picture mode 182 | // https://developer.chrome.com/docs/web-platform/document-picture-in-picture/ 183 | // @ts-expect-error experimental API 184 | const documentPictureInPicture = window.documentPictureInPicture; 185 | async function popup() { 186 | const iframe = iframeGetter(); 187 | const pip = (popupWindow.value = 188 | await documentPictureInPicture.requestWindow({ 189 | width: Math.round((window.innerWidth * state.value.width) / 100), 190 | height: Math.round((window.innerHeight * state.value.height) / 100), 191 | })); 192 | const style = pip.document.createElement("style"); 193 | style.innerHTML = ` 194 | body { 195 | margin: 0; 196 | padding: 0; 197 | } 198 | iframe { 199 | width: 100vw; 200 | height: 100vh; 201 | border: none; 202 | outline: none; 203 | } 204 | `; 205 | /* pip.__VUE_DEVTOOLS_GLOBAL_HOOK__ = hook 206 | pip.__VUE_DEVTOOLS_IS_POPUP__ = true */ 207 | 208 | pip.document.title = iframe?.title ?? `DevTools`; 209 | 210 | pip.addEventListener("resize", () => { 211 | state.value.width = Math.round( 212 | (pip.innerWidth / window.innerWidth) * 100 213 | ); 214 | state.value.height = Math.round( 215 | (pip.innerHeight / window.innerHeight) * 100 216 | ); 217 | }); 218 | pip.addEventListener("pagehide", () => { 219 | popupWindow.value = null; 220 | pip.close(); 221 | }); 222 | return { 223 | pip, 224 | append: () => { 225 | pip.document.head.appendChild(style); 226 | pip.document.body.appendChild(iframe); 227 | }, 228 | }; 229 | } 230 | window.popupIframes[name] = popup; 231 | return { 232 | popup, 233 | }; 234 | } 235 | 236 | // ---- usePosition ---- 237 | const SNAP_THRESHOLD = 2; 238 | 239 | function snapToPoints(value: number) { 240 | if (value < 5) return 0; 241 | if (value > 95) return 100; 242 | if (Math.abs(value - 50) < SNAP_THRESHOLD) return 50; 243 | return value; 244 | } 245 | 246 | export function useDevtools(): Ref<{ 247 | name: string; 248 | iframeSrc: string; 249 | icon: string; 250 | inspector: boolean; 251 | onIframe: string; 252 | }> { 253 | // @ts-expect-error globals 254 | return ref(globalThis.__devtools); 255 | } 256 | 257 | export function usePosition(panelEl: Ref) { 258 | const isDragging = ref(false); 259 | const draggingOffset = reactive({ x: 0, y: 0 }); 260 | const windowSize = reactive({ width: 0, height: 0 }); 261 | const mousePosition = reactive({ x: 0, y: 0 }); 262 | const panelMargins = reactive({ 263 | left: 10, 264 | top: 10, 265 | right: 10, 266 | bottom: 10, 267 | }); 268 | 269 | const safeArea = useScreenSafeArea(); 270 | 271 | watchEffect(() => { 272 | panelMargins.left = safeArea.left.value + 10; 273 | panelMargins.top = safeArea.top.value + 10; 274 | panelMargins.right = safeArea.right.value + 10; 275 | panelMargins.bottom = safeArea.bottom.value + 10; 276 | }); 277 | 278 | const onPointerDown = (e: PointerEvent) => { 279 | isDragging.value = true; 280 | const { left, top, width, height } = panelEl.value!.getBoundingClientRect(); 281 | draggingOffset.x = e.clientX - left - width / 2; 282 | draggingOffset.y = e.clientY - top - height / 2; 283 | }; 284 | 285 | const setWindowSize = () => { 286 | windowSize.width = window.innerWidth; 287 | windowSize.height = window.innerHeight; 288 | }; 289 | 290 | onMounted(() => { 291 | setWindowSize(); 292 | 293 | useWindowEventListener("resize", () => { 294 | setWindowSize(); 295 | }); 296 | 297 | useWindowEventListener("pointerup", () => { 298 | isDragging.value = false; 299 | }); 300 | useWindowEventListener("pointerleave", () => { 301 | isDragging.value = false; 302 | }); 303 | useWindowEventListener("pointermove", (e) => { 304 | if (!isDragging.value) return; 305 | 306 | const centerX = windowSize.width / 2; 307 | const centerY = windowSize.height / 2; 308 | 309 | const x = e.clientX - draggingOffset.x; 310 | const y = e.clientY - draggingOffset.y; 311 | 312 | mousePosition.x = x; 313 | mousePosition.y = y; 314 | 315 | // Get position 316 | const deg = Math.atan2(y - centerY, x - centerX); 317 | const HORIZONTAL_MARGIN = 70; 318 | const TL = Math.atan2(0 - centerY + HORIZONTAL_MARGIN, 0 - centerX); 319 | const TR = Math.atan2( 320 | 0 - centerY + HORIZONTAL_MARGIN, 321 | windowSize.width - centerX 322 | ); 323 | const BL = Math.atan2( 324 | windowSize.height - HORIZONTAL_MARGIN - centerY, 325 | 0 - centerX 326 | ); 327 | const BR = Math.atan2( 328 | windowSize.height - HORIZONTAL_MARGIN - centerY, 329 | windowSize.width - centerX 330 | ); 331 | 332 | state.value.position = 333 | deg >= TL && deg <= TR 334 | ? "top" 335 | : deg >= TR && deg <= BR 336 | ? "right" 337 | : deg >= BR && deg <= BL 338 | ? "bottom" 339 | : "left"; 340 | 341 | state.value.left = snapToPoints((x / windowSize.width) * 100); 342 | state.value.top = snapToPoints((y / windowSize.height) * 100); 343 | }); 344 | }); 345 | 346 | const isVertical = computed( 347 | () => state.value.position === "left" || state.value.position === "right" 348 | ); 349 | 350 | const anchorPos = computed(() => { 351 | const halfWidth = (panelEl.value?.clientWidth || 0) / 2; 352 | const halfHeight = (panelEl.value?.clientHeight || 0) / 2; 353 | 354 | const left = (state.value.left * windowSize.width) / 100; 355 | const top = (state.value.top * windowSize.height) / 100; 356 | 357 | switch (state.value.position) { 358 | case "top": 359 | return { 360 | left: clamp( 361 | left, 362 | halfWidth + panelMargins.left, 363 | windowSize.width - halfWidth - panelMargins.right 364 | ), 365 | top: panelMargins.top + halfHeight, 366 | }; 367 | case "right": 368 | return { 369 | left: windowSize.width - panelMargins.right - halfHeight, 370 | top: clamp( 371 | top, 372 | halfWidth + panelMargins.top, 373 | windowSize.height - halfWidth - panelMargins.bottom 374 | ), 375 | }; 376 | case "left": 377 | return { 378 | left: panelMargins.left + halfHeight, 379 | top: clamp( 380 | top, 381 | halfWidth + panelMargins.top, 382 | windowSize.height - halfWidth - panelMargins.bottom 383 | ), 384 | }; 385 | case "bottom": 386 | default: 387 | return { 388 | left: clamp( 389 | left, 390 | halfWidth + panelMargins.left, 391 | windowSize.width - halfWidth - panelMargins.right 392 | ), 393 | top: windowSize.height - panelMargins.bottom - halfHeight, 394 | }; 395 | } 396 | }); 397 | 398 | const anchorStyle = computed(() => ({ 399 | left: `${anchorPos.value.left}px`, 400 | top: `${anchorPos.value.top}px`, 401 | })); 402 | 403 | const iframeStyle = computed(() => { 404 | // eslint-disable-next-line no-unused-expressions, no-sequences 405 | mousePosition.x, mousePosition.y; 406 | 407 | const halfHeight = (panelEl.value?.clientHeight || 0) / 2; 408 | 409 | const frameMargin = { 410 | left: panelMargins.left + halfHeight, 411 | top: panelMargins.top + halfHeight, 412 | right: panelMargins.right + halfHeight, 413 | bottom: panelMargins.bottom + halfHeight, 414 | }; 415 | 416 | const marginHorizontal = frameMargin.left + frameMargin.right; 417 | const marginVertical = frameMargin.top + frameMargin.bottom; 418 | 419 | const maxWidth = windowSize.width - marginHorizontal; 420 | const maxHeight = windowSize.height - marginVertical; 421 | 422 | const style: CSSProperties = { 423 | zIndex: -1, 424 | pointerEvents: isDragging.value ? "none" : "auto", 425 | width: `min(${state.value.width}vw, calc(100vw - ${marginHorizontal}px))`, 426 | height: `min(${state.value.height}vh, calc(100vh - ${marginVertical}px))`, 427 | }; 428 | 429 | const anchor = anchorPos.value; 430 | const width = Math.min( 431 | maxWidth, 432 | (state.value.width * windowSize.width) / 100 433 | ); 434 | const height = Math.min( 435 | maxHeight, 436 | (state.value.height * windowSize.height) / 100 437 | ); 438 | 439 | const anchorX = anchor?.left || 0; 440 | const anchorY = anchor?.top || 0; 441 | 442 | switch (state.value.position) { 443 | case "top": 444 | case "bottom": 445 | style.left = 0; 446 | style.transform = "translate(-50%, 0)"; 447 | if (anchorX - frameMargin.left < width / 2) 448 | style.left = `${width / 2 - anchorX + frameMargin.left}px`; 449 | else if (windowSize.width - anchorX - frameMargin.right < width / 2) 450 | style.left = `${ 451 | windowSize.width - anchorX - width / 2 - frameMargin.right 452 | }px`; 453 | break; 454 | case "right": 455 | case "left": 456 | style.top = 0; 457 | style.transform = "translate(0, -50%)"; 458 | if (anchorY - frameMargin.top < height / 2) 459 | style.top = `${height / 2 - anchorY + frameMargin.top}px`; 460 | else if (windowSize.height - anchorY - frameMargin.bottom < height / 2) 461 | style.top = `${ 462 | windowSize.height - anchorY - height / 2 - frameMargin.bottom 463 | }px`; 464 | break; 465 | } 466 | 467 | switch (state.value.position) { 468 | case "top": 469 | style.top = 0; 470 | break; 471 | case "right": 472 | style.right = 0; 473 | break; 474 | case "left": 475 | style.left = 0; 476 | break; 477 | case "bottom": 478 | default: 479 | style.bottom = 0; 480 | break; 481 | } 482 | 483 | return style; 484 | }); 485 | 486 | return { 487 | isDragging, 488 | onPointerDown, 489 | isVertical, 490 | anchorStyle, 491 | iframeStyle, 492 | }; 493 | } 494 | --------------------------------------------------------------------------------