diff --git a/AGENTS.md b/AGENTS.md index bf19949dc..80aad67d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Monorepo (`pnpm` workspaces + `turbo`). ESM TypeScript; bundled with `tsdown`. P | `packages/vite` | `@vitejs/devtools-vite` | Nuxt UI for the Vite dev server's plugin pipeline and module transforms. Hub-mounted via `Plugin.devtools.setup`. Serves at `/__devtools-vite/`. | | `packages/oxc` | `@vitejs/devtools-oxc` | Oxc toolchain (oxlint/oxfmt) inspector, donated from [`yuyinws/oxc-inspector`](https://github.com/yuyinws/oxc-inspector) with full history; owned by Leo. Advertised by core as a built-in install launcher in the `viteplus` group (installed on demand), mounted via `DevToolsOxc()` from `@vitejs/devtools-oxc/vite` once present, plus a standalone CLI/client. Its Nuxt client is on the shared stack — UnoCSS via `presetDevToolsUI` and `@vitejs/devtools-ui` components (an oxc-cyan `primary` scale derived from the `BannerOxcDevTools` accent) — and is wired into the turbo build, `vue-tsc` typecheck (own `src/tsconfig.json` reference) and export-snapshot gates. It dogfoods its own oxlint/oxfmt on itself (run `pnpm -C packages/oxc lint`), whose formatting conflicts with the repo-wide antfu ESLint config, so it stays out of the shared ESLint run. | | `packages/vitest` | `@vitejs/devtools-vitest` | Slim launcher for the Vitest UI, in the `viteplus` dock group. A `launcher` dock (only shown when the project uses Vitest) installs `@vitest/ui` on demand, spawns `vitest --ui`, then swaps to an iframe. Serves its favicon at `/__devtools-vitest/`. | +| `packages/agent` | `@vitejs/devtools-agent` | Agent (MCP) access to Vite DevTools. `DevToolsAgent()` registers read-only RPC queries marked with `agent` metadata, exposed through the host's `/__devtools/__mcp` route: `vite:agent:*` (Vite dev-server modules, transforms and plugin activity, backed by `@vitejs/devtools-vite`'s `vite:inspect:*` RPC) and `vite:rolldown:agent:*` (recorded Rolldown builds read from `node_modules/.rolldown` through `@vitejs/devtools-rolldown/node`). Both integrations are optional peers detected with `isPackageExists` at call time; a missing package returns an install hint (`AGDT0003`). | | `packages/webext` | — | Browser extension scaffolding (ancillary). | Meta-introspection ("DevTools for the DevTools") is provided by the official upstream `@devframes/plugin-inspect`, mounted as a built-in via `createPluginFromDevframe` (replaces the former `packages/self-inspect`). @@ -38,10 +39,12 @@ flowchart TD hub --> devframe kit --> hub core --> kit - core --> rolldown & vite & vitest + core --> rolldown & vite & vitest & agent rolldown --> kit & ui vite --> kit & ui vitest --> kit + agent --> kit + agent -.-> rolldown & vite webext --> core ``` @@ -62,6 +65,7 @@ flowchart TD - **WS server** (`packages/core/src/node/ws.ts`): RPC via `devframe/rpc/transports/ws-server`. Auth skipped in build mode or when `devtools.clientAuth` is `false`. - **Hub-mounted Nuxt UI plugins** (rolldown, vite): each implements `Plugin.devtools.setup`, receives a `KitNodeContext`, registers RPC functions, hosts a static Nuxt SPA, and registers its dock entry. - **Built-in devframe plugins** (`@devframes/plugin-inspect` today; `@devframes/plugin-terminals` / `@devframes/plugin-messages`): official portable `DevframeDefinition`s mounted as built-ins by `DevTools()` via kit's `createPluginFromDevframe` (gated by `builtinDevTools`). +- **Built-in agent integration** (`packages/core/src/node/plugins/builtin.ts` + `packages/agent`): when `@vitejs/devtools-agent` is installed, `DevTools()` mounts `DevToolsAgent()` (gated by `builtinDevTools`; manual registration coexists, duplicates are skipped). It has no install launcher — installing the package is the opt-in. Its Rolldown and Vite tools detect their own integration packages with `isPackageExists` rather than importing them eagerly. - **Built-in install launchers** (`packages/core/src/node/plugins/index.ts` + kit's `createInstallLauncher`): keeps core dependency-light. For each optional integration (Rolldown / Vite / Vitest / Oxc) `DevTools()` mounts the real plugin when its package is installed, else registers a discovery launcher that installs it on demand (via `package-manager-detector`) and prompts a restart. The `@vitejs/devtools-*` packages are declared as optional peer deps; their marks are vendored in `packages/core/assets` and served at `/__devtools-assets/` so a launcher icon renders before its package exists. ## Development @@ -111,6 +115,7 @@ All node-side warnings and errors use structured diagnostics via [`nostics`](htt | `VDT` | `packages/vite` | `packages/vite/src/node/diagnostics.ts` | | `VTDT` | `packages/vitest` | `packages/vitest/src/node/diagnostics.ts` | | `OXDT` | `packages/oxc` | `packages/oxc/src/node/diagnostics.ts` | +| `AGDT` | `packages/agent` | `packages/agent/src/node/diagnostics.ts` | `DF` codes belong to the upstream devframe/hub projects — file new ones there. The `DF8xxx` sub-range covers `@devframes/hub` (DF8100–DF8199 docks, DF8200–DF8299 terminals, DF8300–DF8399 messages, DF8400–DF8499 commands). @@ -174,7 +179,7 @@ The sidebar in `docs/.vitepress/config.ts` globs the `errors/` directory by pref ### Scope -- **Node-side only**: `packages/kit/src/node`, `packages/core/src/node`, `packages/rolldown/src/node`, `packages/vite/src/node`, `packages/vitest/src/node`, `packages/oxc/src/node`. +- **Node-side only**: `packages/kit/src/node`, `packages/core/src/node`, `packages/rolldown/src/node`, `packages/vite/src/node`, `packages/vitest/src/node`, `packages/oxc/src/node`, `packages/agent/src/node`. - **Client-side excluded**: Vue components, webcomponents, and browser-only code keep using `console.*` / `throw`. ## Before PRs diff --git a/alias.ts b/alias.ts index 9c0891c10..d2b21e90f 100644 --- a/alias.ts +++ b/alias.ts @@ -14,6 +14,8 @@ export const alias = { '@vitejs/devtools-kit/utils/when': r('kit/src/utils/when.ts'), '@vitejs/devtools-kit/utils/shared-state': r('kit/src/utils/shared-state.ts'), '@vitejs/devtools-kit': r('kit/src/index.ts'), + '@vitejs/devtools-agent': r('agent/src/index.ts'), + '@vitejs/devtools-rolldown/node': r('rolldown/src/node/api.ts'), '@vitejs/devtools-rolldown': r('rolldown/src/index.ts'), '@vitejs/devtools-vite': r('vite/src/index.ts'), '@vitejs/devtools-vitest': r('vitest/src/index.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a802b7b2d..5724f7439 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -114,6 +114,7 @@ export default extendConfig(withMermaid(defineConfig({ text: 'Guide', items: [ { text: 'Getting Started', link: '/guide/' }, + { text: 'Agents', link: '/guide/agents' }, { text: 'Chrome Extension', link: '/guide/chrome-extension' }, ], }, @@ -159,6 +160,14 @@ export default extendConfig(withMermaid(defineConfig({ link: `/errors/${code}`, })), }, + { + text: 'Agent Integration (AGDT)', + collapsed: true, + items: listErrorCodes('AGDT').map(code => ({ + text: code, + link: `/errors/${code}`, + })), + }, { text: 'Rolldown DevTools (RDDT)', collapsed: true, diff --git a/docs/errors/AGDT0001.md b/docs/errors/AGDT0001.md new file mode 100644 index 000000000..0edc7aa81 --- /dev/null +++ b/docs/errors/AGDT0001.md @@ -0,0 +1,20 @@ +--- +outline: deep +--- + +# AGDT0001: Rolldown Session Not Found + +## Message +> Rolldown session "`{id}`" was not found in this project. + +## Cause +An agent requested a build session absent from the selected project's recorded builds. + +## Example +A session id from another project, or a deleted build, is passed to the summary tool. + +## Fix +List recorded sessions on the selected instance and use a returned id. Generate a recorded build when the list is empty. + +## Source +- [`packages/agent/src/node/rolldown/index.ts`](https://github.com/vitejs/devtools/blob/main/packages/agent/src/node/rolldown/index.ts) — `loadSession()` checks that the session belongs to the current project. diff --git a/docs/errors/AGDT0002.md b/docs/errors/AGDT0002.md new file mode 100644 index 000000000..274da30f6 --- /dev/null +++ b/docs/errors/AGDT0002.md @@ -0,0 +1,20 @@ +--- +outline: deep +--- + +# AGDT0002: Vite Inspection Not Active + +## Message +> Vite inspection is not active in this project. + +## Cause +An agent requested Vite inspection data while `@vitejs/devtools-vite` is installed but its inspector is not mounted. + +## Example +Calling `vite:agent:modules` with `builtinDevTools: false` and without `DevToolsViteInspect()` registered, or before restarting after installing the package. + +## Fix +Restart the dev server so the installed package is mounted. With `builtinDevTools: false`, explicitly register `DevToolsViteInspect()` alongside `DevToolsAgent()`. + +## Source +- [`packages/agent/src/node/vite/index.ts`](https://github.com/vitejs/devtools/blob/main/packages/agent/src/node/vite/index.ts) — `inspect()` checks for the registered inspection RPC. diff --git a/docs/errors/AGDT0003.md b/docs/errors/AGDT0003.md new file mode 100644 index 000000000..7c47c109d --- /dev/null +++ b/docs/errors/AGDT0003.md @@ -0,0 +1,20 @@ +--- +outline: deep +--- + +# AGDT0003: Integration Package Not Installed + +## Message +> `{pkg}` is not installed in this project. + +## Cause +An agent called a tool whose data comes from an integration package that cannot be resolved from the project directory. The Rolldown tools need `@vitejs/devtools-rolldown`; the Vite tools need `@vitejs/devtools-vite`. + +## Example +Calling `vite:rolldown:agent:summary` in a project that installed only `@vitejs/devtools-agent`. + +## Fix +Install the named package as a dev dependency and restart the dev server. + +## Source +- [`packages/agent/src/node/integration.ts`](https://github.com/vitejs/devtools/blob/main/packages/agent/src/node/integration.ts) — `assertInstalled()` throws this before an integration's tool runs. diff --git a/docs/errors/index.md b/docs/errors/index.md index fb0409b9d..a31dda612 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -84,3 +84,13 @@ Emitted by `@vitejs/devtools-oxc`. | [OXDT0006](./OXDT0006) | error | Oxfmt Setup Failed | | [OXDT0007](./OXDT0007) | error | Failed to Run Oxfmt | | [OXDT0008](./OXDT0008) | error | Failed to Delete Format Result | + +## Agent Integration (AGDT) + +Emitted by `@vitejs/devtools-agent`. + +| Code | Level | Title | +|------|-------|-------| +| [AGDT0001](./AGDT0001) | error | Rolldown Session Not Found | +| [AGDT0002](./AGDT0002) | error | Vite Inspection Not Active | +| [AGDT0003](./AGDT0003) | error | Integration Package Not Installed | diff --git a/docs/guide/agents.md b/docs/guide/agents.md new file mode 100644 index 000000000..3e41d6b31 --- /dev/null +++ b/docs/guide/agents.md @@ -0,0 +1,170 @@ +--- +outline: deep +--- + +# Agents + +Use `@vitejs/devtools-agent` to let an agent inspect Vite development activity and recorded Rolldown builds through MCP. + +## Choose the data you need + +| You want to investigate | Use | Data source | +| --- | --- | --- | +| Module imports, code transformations or plugin activity during development | Vite tools | Activity already observed by the running dev server | +| Output size, bundled dependencies or changes between builds | Rolldown tools | Recorded builds in `/node_modules/.rolldown` | + +For example, ask “Which plugin changed this module during development?” or “What made this build larger than the previous one?” The agent selects tools, reads measurements and follows up with more queries. Queries read existing data; generating new observations requires visiting the app or recording a build. + +## Connect your agent + +### 1. Install and enable + +Install the agent integration alongside Vite DevTools: + +```bash +pnpm add -D @vitejs/devtools @vitejs/devtools-agent +``` + +For Vite development inspection, also install `@vitejs/devtools-vite`. For Rolldown build inspection, install `@vitejs/devtools-rolldown`: + +```bash +pnpm add -D @vitejs/devtools-vite @vitejs/devtools-rolldown +``` + +Both tool sets stay listed in the agent's MCP tool list. A tool whose package is missing replies with an install hint, so the agent can ask you to install it. + +Enable the host in your Vite configuration. It automatically mounts the installed integrations. + +```ts +import { defineConfig } from 'vite' + +export default defineConfig({ + devtools: { apply: 'serve' }, +}) +``` + +### 2. Configure MCP + +Add the connector to your agent's MCP configuration: + +```json +{ + "mcpServers": { + "devframe": { + "command": "npx", + "args": ["devframe", "connect"] + } + } +} +``` + +Start the dev server and open Vite DevTools in the app to make the host discoverable. Keep the server running while the agent queries it. For connector options, see [devframe](https://devfra.me/guide/). + +### 3. Select the project + +Ask the agent to discover instances, match `rootDir` to your project directory and use the selected port for tool calls. If several servers match, select the intended server before investigating. + +Within that host, Vite queries select an instance and environment with `vite` and `env`. Rolldown queries select a recorded build with `session`. + +## Vite: inspect development activity + +> Find `src/main.ts` in the client environment. Show its importers and identify which transformation steps changed its code. + +Start with `environments`, search with `modules`, then use the exact module id with `module` or `transforms`. Pass the chosen `vite` and `env` to each query. + +Tool names use the prefix `vite:agent:`. + +| Tool | Use it to | +| --- | --- | +| `environments` | Discover instance ids, project roots and environment names | +| `modules` | Search module ids and rank by transform duration or id | +| `module` | Inspect an exact module's imports and importers | +| `transforms` | Read the recorded resolved id and load/transform steps | +| `plugins` | Search plugin resolve/transform timings and call counts | + +### Read transformation code + +`transforms` returns step metadata by default. To inspect code: + +1. Set `includeCode: true`. +2. Select a step with `offset` and `limit: 1`. +3. Read its `code`, then increase `codeOffset` by 4,000 while `codeHasMore` is true. + +Each step returns up to 4,000 UTF-16 code units per call. Queries use recorded steps without triggering another transformation. + +## Rolldown: inspect recorded builds + +Rolldown tools query recorded builds in `/node_modules/.rolldown` while the dev server runs. Enable `build.rolldownOptions.devtools` and run a build to record one. + +> Compare this project's latest two recorded builds. Show the largest output-size increases and trace the dependencies in the affected files. + +Use `project` to confirm the directory and `list-sessions` to select builds. For a single build, follow `summary` → `assets` → `modules` → `trace-dependency`. + +Tool names use the prefix `vite:rolldown:agent:`. + +| Tool | Use it to | +| --- | --- | +| `project` | Confirm project and workspace directories | +| `list-sessions` | List recorded builds, newest first, with their entries | +| `summary` | Read build totals, entries and available data | +| `assets` | Find output files by filename or initial/async/static scope | +| `modules` | Find module ids, optionally within an output file | +| `module` | Inspect an exact module's imports (with their import kind, such as static or dynamic) and importers | +| `packages` | Find packages, duplicate copies and transformed code sizes | +| `plugins` | Query hook timings and call counts by plugin and hook | +| `trace-dependency` | Follow dependency paths for a module, package or output file | +| `compare` | Compare asset, chunk, package or plugin changes between builds | + +### Select a stable build + +For session-based queries, omit `session` or use `latest` to begin. Use the returned session id for follow-up queries so a newly recorded build does not change the investigation. The `module` tool returns `null` when the requested module is absent. Its import list and importer list page separately. + +A single build command can record several sessions seconds apart, such as the client and server builds of a framework. `latest` is the newest of them, so use the entries from `list-sessions` to select the build you mean, and compare sessions that share the same entries. + +`compare` requires two concrete session ids. Deltas are **current minus previous**, with changed items ranked by absolute delta. Hashed filenames are matched heuristically, so renamed outputs can appear as additions and removals. + +## Understand the results + +### Pagination and partial data + +List responses include `items`, `total`, `offset`, `limit` and `hasMore`. Search filters apply before pagination. Most lists allow up to 100 items per page; Vite transformations allow 20 steps. A module's imports and importers use separate offsets and limits (`importsOffset`, `importsLimit`, `importersOffset`, `importersLimit`). Dependency tracing uses a path limit rather than offset pagination. + +| Result | Meaning and next step | +| --- | --- | +| Vite `available: false` | Follow the returned `hint`: install `@vitejs/devtools-vite` and restart, or check manual registration | +| Error naming a missing package | Install the named `@vitejs/devtools-*` package as a dev dependency and restart the dev server | +| Empty Vite modules or transformations | Visit the relevant app page to produce observations, then query again | +| Empty Rolldown sessions | Enable build recording and run a build | +| Package graph unsupported | Package data is unavailable; this does not establish that dependencies are absent | +| Trace `truncated: true` | Limits omitted paths; narrow the target or adjust the path/depth limit | + +Trace paths report where they stop: a recorded entry, graph root, missing node, cycle or depth limit. Returned paths place a recorded entry ahead of a graph boundary, a depth limit, a cycle, or a missing node, and shorter paths come first within each group. A graph root is not necessarily an application entry. Paths show recorded connections; they do not establish which exports survive tree shaking. An output file such as a sourcemap can be present and still have no module graph. + +### Metric meanings + +| Measurement | Meaning | +| --- | --- | +| Asset bytes | Uncompressed emitted file size | +| Initial JS bytes | Uncompressed size of the first emitted file for each initial chunk: an entry chunk and the chunks it imports statically | +| Package bytes | Transformed code size before final output optimization | +| Chunk comparison bytes | Emitted size where available, otherwise transformed size | +| Plugin duration | Sum of recorded hook milliseconds; calls can overlap | + +Plugin timings alone do not establish wall-clock savings or explain an HMR issue. Use them to choose where to inspect next. + +## Manual registration + +With `builtinDevTools: false`, register the agent and any required inspector explicitly: + +```ts +import { DevToolsAgent } from '@vitejs/devtools-agent' +import { DevToolsViteInspect } from '@vitejs/devtools-vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + devtools: { apply: 'serve', builtinDevTools: false }, + plugins: [DevToolsAgent(), DevToolsViteInspect()], +}) +``` + +Include `DevToolsViteInspect()` when inspecting development activity. Rolldown tools are registered whenever `@vitejs/devtools-rolldown` is installed and read recorded logs directly. Set `devtools.mcp` to `false` to disable the host's MCP endpoint. diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 000000000..845e7bc11 --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,21 @@ +# @vitejs/devtools-agent + +Inspect Vite development servers and recorded Rolldown builds from agents through Vite DevTools and `devframe connect`. + +Install alongside `@vitejs/devtools`; the host discovers and mounts the plugin automatically: + +```sh +pnpm add -D @vitejs/devtools @vitejs/devtools-agent +``` + +```ts +import { defineConfig } from 'vite' + +export default defineConfig({ + devtools: { apply: 'serve' }, +}) +``` + +To inspect the Vite dev server, also install `@vitejs/devtools-vite`. To analyze a Rolldown build, install `@vitejs/devtools-rolldown`, enable build recording and run a build. + +See the [Agents guide](https://devtools.vite.dev/guide/agents) for connector setup, project selection and build inspection. diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 000000000..cd777786c --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,50 @@ +{ + "name": "@vitejs/devtools-agent", + "type": "module", + "version": "0.8.0", + "description": "Agent access to Vite DevTools", + "author": "VoidZero Inc.", + "license": "MIT", + "repository": { + "directory": "packages/agent", + "type": "git", + "url": "git+https://github.com/vitejs/devtools.git" + }, + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepack": "pnpm build" + }, + "peerDependencies": { + "@vitejs/devtools-rolldown": "workspace:^", + "@vitejs/devtools-vite": "workspace:^" + }, + "peerDependenciesMeta": { + "@vitejs/devtools-rolldown": { + "optional": true + }, + "@vitejs/devtools-vite": { + "optional": true + } + }, + "dependencies": { + "@vitejs/devtools-kit": "workspace:*", + "local-pkg": "catalog:deps", + "nostics": "catalog:deps", + "zod": "catalog:deps" + }, + "devDependencies": { + "@vitejs/devtools-rolldown": "workspace:*", + "@vitejs/devtools-vite": "workspace:*", + "tsdown": "catalog:build", + "typescript": "catalog:devtools" + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 000000000..e57cec244 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,19 @@ +import type { PluginWithDevTools } from '@vitejs/devtools-kit' +import { rolldownAgentTools } from './node/rolldown' +import { viteAgentTools } from './node/vite' + +export function DevToolsAgent(): PluginWithDevTools { + return { + name: 'vite:devtools:agent', + apply: 'serve', + devtools: { + setup(context) { + // Core auto-loads this plugin; an explicit user entry can coexist. + for (const tool of [...rolldownAgentTools, ...viteAgentTools]) { + if (!context.rpc.definitions.has(tool.name)) + context.rpc.register(tool as Parameters[0]) + } + }, + }, + } +} diff --git a/packages/agent/src/node/diagnostics.ts b/packages/agent/src/node/diagnostics.ts new file mode 100644 index 000000000..99a9068b9 --- /dev/null +++ b/packages/agent/src/node/diagnostics.ts @@ -0,0 +1,20 @@ +import { createConsoleReporter, defineDiagnostics } from 'nostics' + +export const diagnostics = /* #__PURE__ */ defineDiagnostics({ + docsBase: 'https://devtools.vite.dev/errors', + reporters: [createConsoleReporter()], + codes: { + AGDT0001: { + why: (p: { id: string }) => `Rolldown session "${p.id}" was not found in this project.`, + fix: 'List recorded builds in the selected project and use an id from that list.', + }, + AGDT0002: { + why: () => 'Vite inspection is not active in this project.', + fix: 'Restart the dev server so the installed @vitejs/devtools-vite is mounted, or explicitly register DevToolsViteInspect() when built-in integrations are disabled.', + }, + AGDT0003: { + why: (p: { pkg: string }) => `${p.pkg} is not installed in this project.`, + fix: 'Install it as a dev dependency and restart the dev server.', + }, + }, +}) diff --git a/packages/agent/src/node/integration.ts b/packages/agent/src/node/integration.ts new file mode 100644 index 000000000..6b81079a2 --- /dev/null +++ b/packages/agent/src/node/integration.ts @@ -0,0 +1,16 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import { isPackageExists } from 'local-pkg' +import { diagnostics } from './diagnostics' + +export function isInstalled(context: ViteDevToolsNodeContext, pkg: string) { + return isPackageExists(pkg, { paths: [context.cwd] }) +} + +export function assertInstalled(context: ViteDevToolsNodeContext, pkg: string) { + if (!isInstalled(context, pkg)) + throw diagnostics.AGDT0003({ pkg }) +} + +export function missingPackageHint(pkg: string) { + return `${pkg} is not installed in this project. Install it as a dev dependency and restart the dev server.` +} diff --git a/packages/agent/src/node/pagination.ts b/packages/agent/src/node/pagination.ts new file mode 100644 index 000000000..a89ad379c --- /dev/null +++ b/packages/agent/src/node/pagination.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const page = { + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(20), +} + +export const edgePage = { + importsOffset: z.number().int().min(0).default(0), + importsLimit: z.number().int().min(1).max(100).default(20), + importersOffset: z.number().int().min(0).default(0), + importersLimit: z.number().int().min(1).max(100).default(20), +} + +export function paginate(items: T[], offset: number, limit: number) { + return { items: items.slice(offset, offset + limit), total: items.length, offset, limit, hasMore: offset + limit < items.length } +} diff --git a/packages/agent/src/node/rolldown/__tests__/dependency-trace.test.ts b/packages/agent/src/node/rolldown/__tests__/dependency-trace.test.ts new file mode 100644 index 000000000..e322b3434 --- /dev/null +++ b/packages/agent/src/node/rolldown/__tests__/dependency-trace.test.ts @@ -0,0 +1,87 @@ +import type { RolldownEventsReader } from '@vitejs/devtools-rolldown/node' +import { getPackageMeta } from '@vitejs/devtools-rolldown/node' +import { describe, expect, it } from 'vitest' +import { traceDependency } from '../dependency-trace' + +const trace = (reader: RolldownEventsReader, input: Parameters[1]) => traceDependency(reader, input, getPackageMeta) + +function reader(edges: Record, entry = 'entry') { + const modules = new Map(Object.entries(edges).map(([id, importers]) => [id, { id, importers, imports: [] }])) + for (const [id, node] of modules) { + for (const importer of node.importers) + (modules.get(importer)?.imports as { module_id: string }[] | undefined)?.push({ module_id: id }) + } + return { + meta: { inputs: [{ filename: entry }] }, + manager: { + modules, + chunks: new Map([[0, { chunk_id: 0, is_entry: true, entry_module: entry, modules: [...modules.keys()] }]]), + assets: new Map([['app.js', { chunk_id: 0 }]]), + packageGraphReady: false, + }, + } as unknown as RolldownEventsReader +} + +const input = { target: { type: 'module' as const, id: 'dep' }, direction: 'importers' as const, maxDepth: 8, limit: 5 } + +describe('dependency graph evidence', () => { + it('orders importer paths from entry to target', () => { + const result = trace(reader({ dep: ['middle'], middle: ['entry'], entry: [] }), input) + expect(result.paths).toEqual([{ modules: ['entry', 'middle', 'dep'], end: 'entry' }]) + expect(result.truncated).toBe(false) + }) + + it('distinguishes depth limits, cycles and missing graph boundaries', () => { + const graph = reader({ dep: ['middle'], middle: ['entry'], entry: [] }) + expect(trace(graph, { ...input, maxDepth: 1 })).toMatchObject({ truncated: true, paths: [{ modules: ['middle', 'dep'], end: 'depth-limit' }] }) + expect(trace(reader({ dep: ['middle'], middle: ['dep'] }), input).paths[0]?.end).toBe('cycle') + expect(trace(reader({ dep: ['external'] }), input).paths[0]?.end).toBe('missing-node') + expect(trace(reader({ dep: [] }), input).paths[0]?.end).toBe('root') + }) + + it('follows imports and exposes omitted branches', () => { + const graph = reader({ entry: [], dep: ['entry'], another: ['entry'] }) + const result = trace(graph, { ...input, target: { type: 'module', id: 'entry' }, direction: 'imports', limit: 1 }) + expect(result.paths).toEqual([{ modules: ['entry', 'dep'], end: 'root' }]) + expect(result.truncated).toBe(true) + }) + + it('bounds the traversal frontier of a high-fanout graph', () => { + const imports = Array.from({ length: 10000 }, (_, i) => `parent-${i}`) + const result = trace(reader({ dep: imports }), input) + expect(result.paths).toHaveLength(5) + expect(result.edgesVisited).toBeLessThanOrEqual(1000) + expect(result.truncated).toBe(true) + }) + + it('does not equate unavailable package data with a missing dependency', () => { + const result = trace(reader({ dep: [] }), { ...input, target: { type: 'package', id: 'dep' } }) + expect(result.packageGraphSupported).toBe(false) + expect(result.notes).toContain('Package graph data is unavailable; package absence cannot be inferred.') + }) + + it('returns an entry path ahead of shorter graph boundaries', () => { + const result = trace(reader({ + 'dep': ['short-a', 'short-b', 'middle'], + 'short-a': [], + 'short-b': [], + 'middle': ['entry'], + 'entry': [], + }), { ...input, limit: 1 }) + expect(result.paths).toEqual([{ modules: ['entry', 'middle', 'dep'], end: 'entry' }]) + expect(result.truncated).toBe(true) + }) + + it('explains an output file with no module graph', () => { + const graph = reader({ dep: [] }) + graph.manager.assets.set('app.js.map', { filename: 'app.js.map', size: 10, chunk_id: null } as never) + const result = trace(graph, { ...input, target: { type: 'asset', id: 'app.js.map' } }) + expect(result.targetFound).toBe(true) + expect(result.matchedModules).toBe(0) + expect(result.paths).toEqual([]) + expect(result.notes).toContain('This output file has no recorded module graph. Sourcemaps and other static files stop here.') + const missing = trace(graph, { ...input, target: { type: 'asset', id: 'missing.js' } }) + expect(missing.targetFound).toBe(false) + expect(missing.notes).not.toContain('This output file has no recorded module graph. Sourcemaps and other static files stop here.') + }) +}) diff --git a/packages/agent/src/node/rolldown/dependency-trace.ts b/packages/agent/src/node/rolldown/dependency-trace.ts new file mode 100644 index 000000000..da1bfacd8 --- /dev/null +++ b/packages/agent/src/node/rolldown/dependency-trace.ts @@ -0,0 +1,109 @@ +import type { RolldownEventsReader } from '@vitejs/devtools-rolldown/node' + +type PackageMetaReader = typeof import('@vitejs/devtools-rolldown/node').getPackageMeta + +interface TraceInput { + target: { type: 'module' | 'package' | 'asset', id: string } + direction: 'importers' | 'imports' + maxDepth: number + limit: number +} + +interface TracePath { + modules: string[] + end: 'entry' | 'root' | 'missing-node' | 'cycle' | 'depth-limit' +} + +// Both queue size and edge inspection are bounded even for dense graphs. +const MAX_EDGES = 5000 +const MAX_PENDING_PATHS = 1000 +const END_RANK: Record = { + 'entry': 0, + 'root': 1, + 'depth-limit': 2, + 'cycle': 3, + 'missing-node': 4, +} + +export function traceDependency(reader: RolldownEventsReader, input: TraceInput, getPackageMeta: PackageMetaReader) { + const { target, direction, maxDepth, limit } = input + const modules = reader.manager.modules + const entries = new Set(reader.meta?.inputs?.map(input => input.filename) ?? []) + let targets: string[] = [] + let targetFound: boolean | null = false + let assetWithoutModuleGraph = false + if (target.type === 'module') { + targetFound = modules.has(target.id) + targets = targetFound ? [target.id] : [] + } + else if (target.type === 'package') { + const packageMeta = getPackageMeta(reader) + const packages = packageMeta.packages.filter(pkg => pkg.id === target.id || pkg.name === target.id || `${pkg.name}@${pkg.version}` === target.id) + targetFound = packageMeta.isSupported ? packages.length > 0 : null + targets = [...new Set(packages.flatMap(pkg => pkg.files.map(file => file.path)))].filter(id => modules.has(id)) + } + else { + const asset = reader.manager.assets.get(target.id) + targetFound = !!asset + targets = asset?.chunk_id == null ? [] : reader.manager.chunks.get(asset.chunk_id)?.modules ?? [] + assetWithoutModuleGraph = !!asset && targets.length === 0 + } + + const queue = targets.slice(0, MAX_PENDING_PATHS).map(id => [id]) + const paths: TracePath[] = [] + let cursor = 0 + let edgesVisited = 0 + let budgetReached = targets.length > MAX_PENDING_PATHS + const emit = (path: string[], end: TracePath['end']) => { + paths.push({ modules: direction === 'importers' ? path.toReversed() : path, end }) + } + // Collect past `limit` so a recorded entry can outrank shorter boundaries. + while (cursor < queue.length && edgesVisited < MAX_EDGES) { + const path = queue[cursor++]! + const id = path.at(-1)! + const node = modules.get(id) + const next = direction === 'importers' ? node?.importers ?? [] : node?.imports?.map(item => item.module_id) ?? [] + if (!node) { + emit(path, 'missing-node') + } + else if (direction === 'importers' && entries.has(id)) { + emit(path, 'entry') + } + else if (!next.length) { + emit(path, 'root') + } + else if (path.length - 1 >= maxDepth) { + emit(path, 'depth-limit') + } + else { + for (const nextId of next) { + if (edgesVisited >= MAX_EDGES || queue.length - cursor >= MAX_PENDING_PATHS) { + budgetReached = true + break + } + edgesVisited++ + if (path.includes(nextId)) + emit([...path, nextId], 'cycle') + else + queue.push([...path, nextId]) + } + } + } + paths.sort((a, b) => END_RANK[a.end] - END_RANK[b.end] || a.modules.length - b.modules.length) + return { + target, + targetFound, + direction, + matchedModules: targets.length, + paths: paths.slice(0, limit), + truncated: budgetReached || cursor < queue.length || paths.length > limit || paths.some(path => path.end === 'depth-limit'), + edgesVisited, + packageGraphSupported: reader.manager.packageGraphReady, + notes: [ + 'Paths follow the recorded module graph; they do not prove which exports survive tree shaking.', + 'Importer paths are ordered from the entry or boundary toward the target. Returned paths prefer a recorded entry, then a graph boundary, a depth limit, a cycle, or a missing node, with shorter paths first in each group. A root is a graph boundary, not necessarily an application entry.', + ...assetWithoutModuleGraph ? ['This output file has no recorded module graph. Sourcemaps and other static files stop here.'] : [], + ...target.type === 'package' && !reader.manager.packageGraphReady ? ['Package graph data is unavailable; package absence cannot be inferred.'] : [], + ], + } +} diff --git a/packages/agent/src/node/rolldown/index.ts b/packages/agent/src/node/rolldown/index.ts new file mode 100644 index 000000000..2447dd606 --- /dev/null +++ b/packages/agent/src/node/rolldown/index.ts @@ -0,0 +1,217 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import type { RolldownEventsReader } from '@vitejs/devtools-rolldown/node' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { z } from 'zod' +import { diagnostics } from '../diagnostics' +import { assertInstalled } from '../integration' +import { edgePage, page, paginate } from '../pagination' +import { traceDependency } from './dependency-trace' + +const sessionId = z.string().min(1).refine(value => !/[\\/]/.test(value) && !value.includes('..'), 'Expected a session id from list-sessions') +const session = sessionId.default('latest') +const pinnedSession = sessionId.refine(id => id !== 'latest', 'Use a concrete session id when comparing builds') +const query = z.string().default('') + +async function loadRolldown(context: ViteDevToolsNodeContext) { + assertInstalled(context, '@vitejs/devtools-rolldown') + return import('@vitejs/devtools-rolldown/node') +} + +async function loadSession(context: ViteDevToolsNodeContext, requested = 'latest') { + const { getLogsManager } = await loadRolldown(context) + const manager = getLogsManager(context) + const sessions = (await manager.list()).sort((a, b) => b.timestamp - a.timestamp) + const selected = requested === 'latest' ? sessions[0] : sessions.find(session => session.id === requested) + if (!selected) + throw diagnostics.AGDT0001({ id: requested }) + return { id: selected.id, reader: await manager.loadAssetSession(selected.id) } +} + +function totals(id: string, reader: RolldownEventsReader) { + const chunks = reader.manager.chunks + const assets = Array.from(reader.manager.assets.values()) + const firstAssetSize = new Map() + for (const asset of assets) { + if (asset.chunk_id != null && !firstAssetSize.has(asset.chunk_id)) + firstAssetSize.set(asset.chunk_id, asset.size) + } + return { + session: id, + inputs: reader.meta?.inputs ?? [], + buildDurationMs: reader.manager.build_end_time > 0 ? Math.max(0, reader.manager.build_end_time - reader.manager.build_start_time) : null, + modules: reader.manager.modules.size, + chunks: chunks.size, + assets: assets.length, + bundleBytes: assets.reduce((sum, asset) => sum + asset.size, 0), + initialJsBytes: Array.from(chunks.values()).reduce((sum, chunk) => sum + (chunk.is_initial ? firstAssetSize.get(chunk.chunk_id) ?? 0 : 0), 0), + } +} + +export const rolldownAgentTools = [ + defineRpcFunction({ + name: 'vite:rolldown:agent:project', + type: 'query', + agent: { description: 'Rolldown build analysis: confirm the connected project directory before selecting recorded builds. Match cwd to the requested project; workspaceRoot identifies its workspace.' }, + setup: context => ({ handler: () => ({ cwd: context.cwd, workspaceRoot: context.workspaceRoot }) }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:list-sessions', + type: 'query', + args: [z.object(page)], + returns: z.unknown(), + agent: { description: 'Rolldown recorded builds: discover session ids for build-output analysis and comparisons, newest first. These are existing logs; this tool does not run a build. One build command can record several sessions seconds apart, such as client and server builds, so pick a session by its inputs instead of assuming the newest one, and compare sessions with matching inputs. If empty, enable Rolldown recording and generate a build.' }, + setup: context => ({ + handler: async ({ offset = 0, limit = 20 }) => { + const sessions = (await (await loadRolldown(context)).getLogsManager(context).list()).sort((a, b) => b.timestamp - a.timestamp) + return paginate(sessions.map(({ id, timestamp, alias, meta }) => ({ id, timestamp, alias, inputs: meta.inputs })), offset, limit) + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:summary', + type: 'query', + args: [z.object({ session })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: start here to investigate output size and build composition. Returns emitted-byte totals, module/chunk counts and available data. Initial JS bytes count the first emitted file of each initial chunk; a chunk is initial when a user-defined entry reaches it through static imports. session defaults to latest, the newest recorded session, which in a multi-build project can be a secondary build such as a server bundle; check the returned inputs and module count, or select an id from list-sessions. Pin the returned session id for follow-up Rolldown queries.' }, + setup: context => ({ + handler: async ({ session }) => { + const { id, reader } = await loadSession(context, session) + return { + ...totals(id, reader), + available: { packageGraph: reader.manager.packageGraphReady, plugins: reader.meta?.plugins?.length ?? 0 }, + notes: [ + 'Output sizes are uncompressed emitted bytes. Plugin hook times can overlap and are not wall-clock build time.', + 'Initial JS bytes count the first emitted file of each initial chunk.', + ], + } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:assets', + type: 'query', + args: [z.object({ session, query, scope: z.enum(['all', 'initial', 'async', 'static']).default('all'), ...page })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: find large emitted files by filename substring and initial/async/static scope, ranked by uncompressed bytes. session defaults to latest. Filters precede pagination; pass a filename to the Rolldown modules tool as asset to inspect its contents.' }, + setup: context => ({ + handler: async ({ session, query = '', scope = 'all', offset = 0, limit = 20 }) => { + const { id, reader } = await loadSession(context, session) + const assets = Array.from(reader.manager.assets.values()).map((asset) => { + const chunk = asset.chunk_id == null ? undefined : reader.manager.chunks.get(asset.chunk_id) + return { filename: asset.filename, bytes: asset.size, scope: asset.chunk_id == null ? 'static' : chunk?.is_initial ? 'initial' : 'async', moduleCount: chunk?.modules.length ?? 0 } + }).filter(asset => asset.filename.includes(query) && (scope === 'all' || scope === asset.scope)).sort((a, b) => b.bytes - a.bytes || a.filename.localeCompare(b.filename)) + return { session: id, ...paginate(assets, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:modules', + type: 'query', + args: [z.object({ session, query, asset: z.string().optional(), sortBy: z.enum(['id', 'importers']).default('id'), ...page })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: find module ids by substring, optionally restricted to an emitted asset, to investigate bundle composition. session defaults to latest. Sort by id or importer count; use the Rolldown module or trace-dependency tool for graph details.' }, + setup: context => ({ + handler: async ({ session, query = '', asset, sortBy = 'id', offset = 0, limit = 20 }) => { + const { id, reader } = await loadSession(context, session) + const chunkId = asset ? reader.manager.assets.get(asset)?.chunk_id : undefined + const included = asset ? new Set(chunkId == null ? [] : reader.manager.chunks.get(chunkId)?.modules ?? []) : undefined + const modules = Array.from(reader.manager.modules.values()) + .filter(module => module.id.includes(query) && (!included || included.has(module.id))) + .map(module => ({ id: module.id, imports: module.imports?.length ?? 0, importers: module.importers?.length ?? 0 })) + .sort((a, b) => (sortBy === 'importers' ? b.importers - a.importers : 0) || a.id.localeCompare(b.id)) + return { session: id, ...paginate(modules, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:module', + type: 'query', + args: [z.object({ session, module: z.string().min(1), ...edgePage })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: inspect imports and importers for an exact module id obtained from Rolldown modules. Imports are {id, kind} pairs where kind is the import form, such as import-statement or dynamic-import; importers are module id strings. Page imports with importsOffset/importsLimit and importers with importersOffset/importersLimit; the two lists do not share a cursor. session defaults to latest. Returns null when absent; use Rolldown trace-dependency for multi-hop paths.' }, + setup: context => ({ + handler: async ({ session, module, importsOffset = 0, importsLimit = 20, importersOffset = 0, importersLimit = 20 }) => { + const { id, reader } = await loadSession(context, session) + const info = reader.manager.modules.get(module) + return info ? { session: id, id: module, imports: paginate((info.imports ?? []).map(edge => ({ id: edge.module_id, kind: edge.kind })), importsOffset, importsLimit), importers: paginate(info.importers ?? [], importersOffset, importersLimit) } : null + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:packages', + type: 'query', + args: [z.object({ session, query, duplicatedOnly: z.boolean().default(false), ...page })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: find dependencies by package name/id and optionally filter duplicate versions or copies. session defaults to latest. Ranked by transformed code bytes, before final output optimization. supported=false means package graph data is unavailable.' }, + setup: context => ({ + handler: async ({ session, query = '', duplicatedOnly = false, offset = 0, limit = 20 }) => { + const { id, reader } = await loadSession(context, session) + const meta = (await loadRolldown(context)).getPackageMeta(reader) + const packages = meta.packages.filter(pkg => (pkg.name.includes(query) || pkg.id.includes(query)) && (!duplicatedOnly || pkg.duplicated)) + .map(pkg => ({ id: pkg.id, name: pkg.name, version: pkg.version, transformedBytes: pkg.transformedCodeSize, files: pkg.files.length, duplicated: !!pkg.duplicated, used: pkg.isUsed })) + .sort((a, b) => b.transformedBytes - a.transformedBytes || a.id.localeCompare(b.id)) + return { session: id, supported: meta.isSupported, sizeBasis: 'transformed-code-bytes', ...paginate(packages, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:plugins', + type: 'query', + args: [z.object({ session, query, hook: z.enum(['total', 'resolve', 'load', 'transform']).default('total'), sortBy: z.enum(['duration', 'calls']).default('duration'), ...page })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: investigate build-plugin hook activity by plugin name and hook, ranked by duration or call count. session defaults to latest. Durations sum recorded hook milliseconds and may overlap; they do not measure wall-clock savings.' }, + setup: context => ({ + handler: async ({ session, query = '', hook = 'total', sortBy = 'duration', offset = 0, limit = 20 }) => { + const { id, reader } = await loadSession(context, session) + const plugins = Array.from((await reader.readPluginBuildMetricsSummary()).values()) + .filter(plugin => plugin.plugin_name.includes(query)) + .map(plugin => ({ id: plugin.plugin_id, name: plugin.plugin_name, durationMs: plugin[hook].duration, calls: plugin[hook].count })) + .sort((a, b) => (sortBy === 'duration' ? b.durationMs - a.durationMs : b.calls - a.calls) || a.id - b.id) + return { session: id, hook, durationBasis: 'sum-of-recorded-hook-calls', ...paginate(plugins, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:trace-dependency', + type: 'query', + args: [z.object({ session, target: z.object({ type: z.enum(['module', 'package', 'asset']), id: z.string().min(1) }), direction: z.enum(['importers', 'imports']).default('importers'), maxDepth: z.number().int().min(1).max(32).default(8), limit: z.number().int().min(1).max(100).default(5) })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded build: trace dependency paths to investigate how a module, package or emitted file is connected to entries. session defaults to latest; use an exact module id, package name/id or filename from Rolldown queries. Importer paths run toward the target. Returned paths prefer a recorded entry, then a graph boundary, with shorter paths first in each group. Each path reports its stopping reason; truncated signals omitted paths. Paths do not establish which exports survive tree shaking. An output file with an empty module list, such as a sourcemap, has no graph to trace.' }, + setup: context => ({ + handler: async ({ session, target, direction = 'importers', maxDepth = 8, limit = 5 }) => { + const { id, reader } = await loadSession(context, session) + return { session: id, ...traceDependency(reader, { target, direction, maxDepth, limit }, (await loadRolldown(context)).getPackageMeta) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:rolldown:agent:compare', + type: 'query', + args: [z.object({ previous: pinnedSession, current: pinnedSession, kind: z.enum(['assets', 'chunks', 'packages', 'plugins']).default('assets'), ...page })], + returns: z.unknown(), + agent: { description: 'Rolldown recorded builds: compare output and plugin metrics between a previous and a current session, both concrete ids from list-sessions; latest is not accepted. Deltas are current minus previous. Changed items are ranked by absolute delta before pagination. Assets use emitted bytes, packages use transformed bytes, chunks use emitted bytes with a transformed fallback, and plugins use accumulated hook milliseconds.' }, + setup: context => ({ + handler: async ({ previous, current, kind = 'assets', offset = 0, limit = 20 }) => { + const [previousBuild, currentBuild] = await Promise.all([loadSession(context, previous), loadSession(context, current)]) + const details = await (await loadRolldown(context)).createSessionCompareDetails(previousBuild.reader, currentBuild.reader) + const previousTotals = totals(previousBuild.id, previousBuild.reader) + const currentTotals = totals(currentBuild.id, currentBuild.reader) + const changes = details[kind].filter(item => item.status !== 'unchanged') + return { + previous: previousTotals, + current: currentTotals, + delta: { + bundleBytes: currentTotals.bundleBytes - previousTotals.bundleBytes, + initialJsBytes: currentTotals.initialJsBytes - previousTotals.initialJsBytes, + buildDurationMs: currentTotals.buildDurationMs == null || previousTotals.buildDurationMs == null ? null : currentTotals.buildDurationMs - previousTotals.buildDurationMs, + }, + kind, + metric: kind === 'plugins' ? 'accumulated-hook-ms' : kind === 'packages' ? 'transformed-code-bytes' : kind === 'chunks' ? 'emitted-bytes-or-transformed-fallback' : 'emitted-bytes', + packageGraphSupported: { previous: previousBuild.reader.manager.packageGraphReady, current: currentBuild.reader.manager.packageGraphReady }, + ...paginate(changes, offset, limit), + notes: ['Hashed filenames are matched heuristically; renamed outputs can appear as additions/removals. Timing differences alone do not establish a regression.'], + } + }, + }), + }), +] as const diff --git a/packages/agent/src/node/vite/index.ts b/packages/agent/src/node/vite/index.ts new file mode 100644 index 000000000..9ffc7cf09 --- /dev/null +++ b/packages/agent/src/node/vite/index.ts @@ -0,0 +1,109 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import type { ViteInspectModuleInfo, ViteInspectModuleTransformInfo, ViteInspectPluginMetric } from '@vitejs/devtools-vite' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import { z } from 'zod' +import { diagnostics } from '../diagnostics' +import { assertInstalled, isInstalled, missingPackageHint } from '../integration' +import { edgePage, page, paginate } from '../pagination' + +const scope = { vite: z.string().min(1), env: z.string().min(1) } +const VITE_PACKAGE = '@vitejs/devtools-vite' + +function inspect(context: ViteDevToolsNodeContext) { + assertInstalled(context, VITE_PACKAGE) + if (!context.rpc.definitions.has('vite:inspect:get-metadata')) + throw diagnostics.AGDT0002() + return context.rpc +} + +export const viteAgentTools = [ + defineRpcFunction({ + name: 'vite:agent:environments', + args: [], + type: 'query', + returns: z.unknown(), + agent: { description: 'Vite development session: start here to investigate dev-time modules, transforms or plugin activity. Discover instance ids, project roots and environments; pass the chosen vite and env to all other Vite agent tools. available=false means inspection is inactive; hint explains how to enable it. Data reflects observed activity in this server session.' }, + setup: context => ({ + handler: async () => { + if (!isInstalled(context, VITE_PACKAGE)) + return { available: false, instances: [], hint: missingPackageHint(VITE_PACKAGE) } + if (!context.rpc.definitions.has('vite:inspect:get-metadata')) + return { available: false, instances: [], hint: 'Vite inspection is not active. Restart the dev server, or register DevToolsViteInspect() when built-in integrations are disabled.' } + const metadata = await (await context.rpc.getHandler('vite:inspect:get-metadata'))() + return { available: true, instances: metadata.instances.map((instance: { vite: string, root: string, environments: string[] }) => ({ vite: instance.vite, root: instance.root, environments: instance.environments })) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:agent:modules', + type: 'query', + returns: z.unknown(), + args: [z.object({ ...scope, query: z.string().default(''), sortBy: z.enum(['id', 'duration']).default('duration'), ...page })], + agent: { description: 'Vite development session: search observed modules to investigate dev-time dependencies and transform costs. First select vite and env with environments. Filter by id substring before pagination; sort by id or recorded transform milliseconds. Only observed modules are included, so an empty list may mean the app has not requested them yet.' }, + setup: context => ({ + handler: async ({ vite, env, query = '', sortBy = 'duration', offset = 0, limit = 20 }) => { + const modules: ViteInspectModuleInfo[] = await (await inspect(context).getHandler('vite:inspect:get-modules-list'))({ vite, env }) + const items = modules.filter(module => module.id.includes(query)) + .map(module => ({ id: module.id, imports: module.deps.length, importers: module.importers.length, durationMs: module.totalTime, virtual: module.virtual })) + .sort((a, b) => (sortBy === 'duration' ? b.durationMs - a.durationMs : 0) || a.id.localeCompare(b.id)) + return { vite, env, ...paginate(items, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:agent:module', + type: 'query', + returns: z.unknown(), + args: [z.object({ ...scope, module: z.string().min(1), ...edgePage })], + agent: { description: 'Vite development session: inspect current imports and importers for an exact id from Vite modules. First select vite and env with environments. Page imports with importsOffset/importsLimit and importers with importersOffset/importersLimit; the two lists do not share a cursor. module=null means the id is absent from observed data. Use Vite transforms for its recorded resolved id and transformation steps.' }, + setup: context => ({ + handler: async ({ vite, env, module, importsOffset = 0, importsLimit = 20, importersOffset = 0, importersLimit = 20 }) => { + const modules: ViteInspectModuleInfo[] = await (await inspect(context).getHandler('vite:inspect:get-modules-list'))({ vite, env }) + const found = modules.find(item => item.id === module) + return { vite, env, module: found ? { id: found.id, virtual: found.virtual, durationMs: found.totalTime, imports: paginate(found.deps, importsOffset, importsLimit), importers: paginate(found.importers, importersOffset, importersLimit) } : null } + }, + }), + }), + defineRpcFunction({ + name: 'vite:agent:transforms', + type: 'query', + returns: z.unknown(), + args: [z.object({ ...scope, module: z.string().min(1), includeCode: z.boolean().default(false), codeOffset: z.number().int().min(0).default(0), ...page, limit: z.number().int().min(1).max(20).default(10) })], + agent: { description: 'Vite development session: inspect recorded load/transform steps and the resolved module id to investigate dev-time code changes. Select vite and env with environments; use an id from Vite modules. Reads existing records without triggering a transform. Code is omitted unless includeCode=true; select a step with offset and limit=1, then advance codeOffset in 4000 UTF-16 code-unit increments while codeHasMore. Empty steps mean no recorded transformations.' }, + setup: context => ({ + handler: async ({ vite, env, module, includeCode = false, codeOffset = 0, offset = 0, limit = 10 }) => { + const info: ViteInspectModuleTransformInfo = await (await inspect(context).getHandler('vite:inspect:get-module-transform-info'))({ vite, env }, module) + const steps = info.transforms.map((step, index) => ({ + index, + plugin: step.name, + pluginId: step.plugin_id, + durationMs: Math.max(0, step.end - step.start), + error: step.error?.message, + codeLength: step.result?.length ?? 0, + ...(includeCode ? { code: step.result?.slice(codeOffset, codeOffset + 4000) ?? null, codeOffset, codeHasMore: (step.result?.length ?? 0) > codeOffset + 4000 } : {}), + })) + return { vite, env, module, resolvedId: info.resolvedId, ...paginate(steps, offset, limit) } + }, + }), + }), + defineRpcFunction({ + name: 'vite:agent:plugins', + type: 'query', + returns: z.unknown(), + args: [z.object({ ...scope, query: z.string().default(''), ...page })], + agent: { description: 'Vite development session: investigate plugin resolve and transform activity already observed by this dev server. First select vite and env with environments. Filter by plugin name; results are ranked by accumulated hook milliseconds. Times may overlap and do not establish wall-clock savings or the cause of an HMR issue.' }, + setup: context => ({ + handler: async ({ vite, env, query = '', offset = 0, limit = 20 }) => { + const plugins: ViteInspectPluginMetric[] = await (await inspect(context).getHandler('vite:inspect:get-plugin-metrics'))({ vite, env }) + const items = plugins.filter(plugin => plugin.name.includes(query)).map(plugin => ({ + id: plugin.plugin_id, + name: plugin.name, + resolve: { calls: plugin.resolveId.invokeCount, durationMs: plugin.resolveId.totalTime }, + transform: { calls: plugin.transform.invokeCount, durationMs: plugin.transform.totalTime }, + durationMs: plugin.resolveId.totalTime + plugin.transform.totalTime, + })).sort((a, b) => b.durationMs - a.durationMs || a.name.localeCompare(b.name)) + return { vite, env, ...paginate(items, offset, limit) } + }, + }), + }), +] as const diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 000000000..1e0ab655b --- /dev/null +++ b/packages/agent/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "lib": ["esnext", "dom"] + }, + "include": ["src"] +} diff --git a/packages/agent/tsdown.config.ts b/packages/agent/tsdown.config.ts new file mode 100644 index 000000000..c66d26270 --- /dev/null +++ b/packages/agent/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + tsconfig: '../../tsconfig.base.json', + target: 'esnext', + dts: true, +}) diff --git a/packages/core/package.json b/packages/core/package.json index b09c85403..a25111c33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ "cli": "DEBUG='vite:devtools:*' tsx src/node/cli.ts" }, "peerDependencies": { + "@vitejs/devtools-agent": "workspace:^", "@vitejs/devtools-oxc": "workspace:^", "@vitejs/devtools-rolldown": "workspace:^", "@vitejs/devtools-vite": "workspace:^", @@ -53,6 +54,9 @@ "vite": "*" }, "peerDependenciesMeta": { + "@vitejs/devtools-agent": { + "optional": true + }, "@vitejs/devtools-oxc": { "optional": true }, @@ -86,6 +90,7 @@ }, "devDependencies": { "@clack/prompts": "catalog:inlined", + "@vitejs/devtools-agent": "workspace:*", "@vitejs/devtools-rolldown": "workspace:*", "@vitejs/devtools-ui": "workspace:*", "@vitejs/devtools-vite": "workspace:*", diff --git a/packages/core/src/node/__tests__/rolldown-agent.test.ts b/packages/core/src/node/__tests__/rolldown-agent.test.ts new file mode 100644 index 000000000..6d35352a6 --- /dev/null +++ b/packages/core/src/node/__tests__/rolldown-agent.test.ts @@ -0,0 +1,157 @@ +import type { ViteDevServer } from 'vite' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { normalize } from 'pathe' +import { createServer } from 'vite' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DevToolsAgent } from '../../../../agent/src' +import { DevToolsViteInspect } from '../../../../vite/src/node/inspect/plugin' +import { normalizeDevToolsConfig } from '../config' +import { DevToolsServer } from '../plugins/server' + +// The temp project has no node_modules; treat the integration packages as installed. +vi.mock('local-pkg', async importOriginal => ({ + ...await importOriginal(), + isPackageExists: () => true, +})) + +let server: ViteDevServer | undefined +let root: string +let origin: string +let requestId = 0 + +afterEach(async () => { + await server?.close() + server = undefined + vi.unstubAllEnvs() + if (root) + await rm(root, { recursive: true, force: true }) +}) + +async function start(viteInspect = false) { + root = await realpath(await mkdtemp(join(tmpdir(), 'rolldown-agent-'))) + await mkdir(join(root, 'node_modules/.rolldown'), { recursive: true }) + vi.stubEnv('DEVFRAME_INSTANCES_DIR', join(root, 'instances')) + server = await createServer({ + configFile: false, + root, + logLevel: 'silent', + devtools: false, + plugins: [DevToolsAgent(), DevToolsAgent(), ...(viteInspect ? [DevToolsViteInspect()] : []), DevToolsServer({}, normalizeDevToolsConfig(true, 'localhost'))], + server: { host: '127.0.0.1', port: 0, watch: null }, + }) + await server.listen() + origin = new URL(server.resolvedUrls!.local[0]!).origin +} + +async function rpc(method: string, params: object = {}) { + const response = await fetch(`${origin}/__devtools/__mcp`, { + method: 'POST', + headers: { 'Origin': origin, 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++requestId, method, params }), + }) + expect(response.ok).toBe(true) + const text = await response.text() + const body = JSON.parse(text.startsWith('event:') || text.startsWith('data:') ? text.split('\n').find(line => line.startsWith('data:'))!.slice(5) : text) + expect(body.error).toBeUndefined() + return body.result +} + +function data(result: any) { + expect(result.isError, JSON.stringify(result)).not.toBe(true) + return JSON.parse(result.content[0].text) +} + +describe('rolldown agent over the Vite DevTools MCP endpoint', () => { + it('auto-exposes tools, identifies the project, validates inputs and reports missing sessions', async () => { + await start() + const { tools } = await rpc('tools/list') + const tool = (suffix: string) => tools.find((entry: any) => entry.name.endsWith(`agent_${suffix}`))! + expect(tools.filter((entry: any) => entry.name.includes('rolldown_agent_'))).toHaveLength(10) + const call = (suffix: string, args = {}) => rpc('tools/call', { name: tool(suffix).name, arguments: args }) + expect(tool('summary').annotations.readOnlyHint).toBe(true) + expect(tool('summary').inputSchema.properties.arg0.properties.session.type).toBe('string') + expect(data(await call('project')).cwd).toBe(normalize(root)) + expect(data(await call('list-sessions', { arg0: {} }))).toMatchObject({ total: 0, items: [], hasMore: false }) + expect((await call('summary', { arg0: { session: '../other' } })).isError).toBe(true) + expect((await call('list-sessions', { arg0: { limit: 101 } })).isError).toBe(true) + expect((await call('summary', { arg0: { session: 'missing' } })).isError).toBe(true) + expect((await call('compare', { arg0: { previous: 'latest', current: 'missing' } })).isError).toBe(true) + expect((await call('trace-dependency', { arg0: { target: { type: 'module', id: 'dep' }, maxDepth: 1000 } })).isError).toBe(true) + expect(tools.some((entry: any) => /delete-session|rename-session|run-build|wait-for-build/.test(entry.name))).toBe(false) + }) + + it('paginates recorded builds newest first', async () => { + await start() + for (const [id, bytes] of [['before', 100], ['after', 140]] as const) { + const dir = join(root, 'node_modules/.rolldown', id) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'meta.json'), JSON.stringify({ action: 'SessionMeta', timestamp: bytes })) + await writeFile(join(dir, 'logs.json'), '') + } + const { tools } = await rpc('tools/list') + const name = tools.find((entry: any) => entry.name.endsWith('agent_list-sessions')).name + const result = data(await rpc('tools/call', { name, arguments: { arg0: { limit: 1 } } })) + expect(result.total).toBe(2) + expect(result.items.map((session: any) => session.id)).toEqual(['after']) + }) +}) + +describe('vite agent over the MCP endpoint', () => { + it('reports inactive inspection without affecting Rolldown tools', async () => { + await start() + const { tools } = await rpc('tools/list') + const name = tools.find((tool: any) => tool.name.endsWith('vite_agent_environments')).name + expect(data(await rpc('tools/call', { name }))).toMatchObject({ available: false, instances: [], hint: expect.stringContaining('DevToolsViteInspect()') }) + }) + + it('reads recorded transforms and scopes queries to a Vite environment', async () => { + await start(true) + await writeFile(join(root, 'entry.js'), `export const message = "${'x'.repeat(5000)}"`) + await server!.transformRequest('/entry.js') + const { tools } = await rpc('tools/list') + const call = (suffix: string, args?: object) => rpc('tools/call', { + name: tools.find((tool: any) => tool.name.endsWith(`vite_agent_${suffix}`)).name, + ...(args ? { arguments: { arg0: args } } : {}), + }) + const discovery = data(await call('environments')) + expect(discovery.available).toBe(true) + const scope = { vite: discovery.instances[0].vite, env: 'client' } + expect(discovery.instances[0].environments).toContain(scope.env) + const modules = data(await call('modules', { ...scope, query: 'entry.js', limit: 1 })) + expect(modules.items).toHaveLength(1) + const module = modules.items[0].id + const detail = data(await call('module', { ...scope, module })) + expect(detail.module.id).toBe(module) + expect(detail.module.importers.items).toEqual([]) + await writeFile(join(root, 'lib.js'), 'export const value = 1\n') + for (const name of ['a', 'b', 'c']) { + await writeFile(join(root, `${name}.js`), `import { value } from './lib.js'\nexport const name = value\n`) + await server!.transformRequest(`/${name}.js`) + } + await server!.transformRequest('/lib.js') + const libModules = data(await call('modules', { ...scope, query: '/lib.js', limit: 20 })) + const libId = libModules.items.find((item: { id: string }) => item.id.endsWith('/lib.js'))?.id + const paged = data(await call('module', { ...scope, module: libId, importsLimit: 5, importersOffset: 1, importersLimit: 1 })) + expect(paged.module.importers.total).toBeGreaterThanOrEqual(3) + expect(paged.module.importers).toMatchObject({ offset: 1, limit: 1, hasMore: true }) + expect(paged.module.importers.items).toHaveLength(1) + expect(paged.module.imports).toMatchObject({ offset: 0, limit: 5 }) + const transforms = data(await call('transforms', { ...scope, module })) + expect(transforms.items.length).toBeGreaterThan(0) + expect(transforms.items.every((step: any) => !('code' in step))).toBe(true) + const offset = transforms.items.findIndex((step: any) => step.codeLength > 4000) + expect(offset).toBeGreaterThanOrEqual(0) + const first = data(await call('transforms', { ...scope, module, includeCode: true, offset, limit: 1 })) + expect(first.items[0].code).toHaveLength(4000) + expect(first.items[0].codeHasMore).toBe(true) + const rest = data(await call('transforms', { ...scope, module, includeCode: true, offset, limit: 1, codeOffset: 4000 })) + expect(first.items[0].code.length + rest.items[0].code.length).toBe(first.items[0].codeLength) + const plugins = data(await call('plugins', { ...scope, query: 'vite:', limit: 2 })) + expect(plugins.items.length).toBeGreaterThan(0) + expect(plugins.items.every((plugin: any) => plugin.name.includes('vite:'))).toBe(true) + expect((await call('modules', { ...scope, env: 'missing' })).isError).toBe(true) + expect((await call('transforms', { ...scope, module, limit: 21 })).isError).toBe(true) + }) +}) diff --git a/packages/core/src/node/plugins/__tests__/index.test.ts b/packages/core/src/node/plugins/__tests__/index.test.ts index 27fec17f0..c30e7c4fe 100644 --- a/packages/core/src/node/plugins/__tests__/index.test.ts +++ b/packages/core/src/node/plugins/__tests__/index.test.ts @@ -24,8 +24,9 @@ describe('devTools', () => { await DevTools({ cwd }) - expect(vi.mocked(isPackageExists)).toHaveBeenCalledTimes(4) + expect(vi.mocked(isPackageExists)).toHaveBeenCalledTimes(5) expect(vi.mocked(isPackageExists).mock.calls).toEqual([ + ['@vitejs/devtools-agent', { paths: [resolvedCwd] }], ['@vitejs/devtools-rolldown', { paths: [resolvedCwd] }], ['@vitejs/devtools-vite', { paths: [resolvedCwd] }], ['@vitejs/devtools-vitest', { paths: [resolvedCwd] }], @@ -33,6 +34,17 @@ describe('devTools', () => { ]) }) + it('automatically registers the installed agent integration', async () => { + vi.mocked(isPackageExists).mockImplementation(name => name === '@vitejs/devtools-agent') + try { + const plugins = await DevTools({ cwd: '/project' }) + expect(plugins.filter(plugin => plugin.name === 'vite:devtools:agent')).toHaveLength(1) + } + finally { + vi.mocked(isPackageExists).mockImplementation(() => false) + } + }) + it('populates the resolved config for the manual plugin', async () => { let observedDevTools: unknown const plugins = await DevTools({ diff --git a/packages/core/src/node/plugins/builtin.ts b/packages/core/src/node/plugins/builtin.ts index de700c33f..1ef61550c 100644 --- a/packages/core/src/node/plugins/builtin.ts +++ b/packages/core/src/node/plugins/builtin.ts @@ -110,6 +110,11 @@ export async function DevToolsBuiltin(options: { return [] const cwd = resolve(options.cwd ?? process.cwd()) + // Agent access is opt-in through installation and uses the existing hub MCP route. + if (isPackageExists('@vitejs/devtools-agent', { paths: [cwd] })) { + plugins.push(import('@vitejs/devtools-agent').then(m => m.DevToolsAgent())) + } + const launchers: BuiltinLauncherIntegration[] = [] for (const integration of BUILTIN_LAUNCHER_INTEGRATIONS) { if (isPackageExists(integration.pkg, { paths: [cwd] })) { diff --git a/packages/rolldown/package.json b/packages/rolldown/package.json index 628fe5d09..55d3442b7 100644 --- a/packages/rolldown/package.json +++ b/packages/rolldown/package.json @@ -20,6 +20,7 @@ "exports": { ".": "./dist/index.mjs", "./dirs": "./dist/dirs.mjs", + "./node": "./dist/node.mjs", "./package.json": "./package.json" }, "types": "./dist/index.d.mts", diff --git a/packages/rolldown/src/node/api.ts b/packages/rolldown/src/node/api.ts new file mode 100644 index 000000000..c71f35c41 --- /dev/null +++ b/packages/rolldown/src/node/api.ts @@ -0,0 +1,12 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import { join } from 'pathe' +import { RolldownLogsManager } from './rolldown/logs-manager' + +// Internal, unstable API for `@vitejs/devtools-agent`; do not use it directly. +export type { RolldownEventsReader } from './rolldown/events-reader' +export { getPackageMeta } from './rpc/functions/rolldown-get-packages' +export { createSessionCompareDetails } from './rpc/functions/rolldown-get-session-compare-details' +// Agent reads are scoped to the connected project, independently of UI fallback. +export function getLogsManager(context: ViteDevToolsNodeContext): RolldownLogsManager { + return new RolldownLogsManager(join(context.cwd, 'node_modules', '.rolldown')) +} diff --git a/packages/rolldown/src/node/rolldown/log-cache.ts b/packages/rolldown/src/node/rolldown/log-cache.ts index f4efc850e..9c4cc6ce8 100644 --- a/packages/rolldown/src/node/rolldown/log-cache.ts +++ b/packages/rolldown/src/node/rolldown/log-cache.ts @@ -11,7 +11,7 @@ export const ROLLDOWN_LOG_READER_INDEX_CACHE_FILENAME = 'reader-index.json' export const ROLLDOWN_LOG_PACKAGE_SUMMARY_CACHE_FILENAME = 'package-summary.json' export const ROLLDOWN_LOG_PLUGIN_SUMMARY_CACHE_FILENAME = 'plugin-summary.json' export const ROLLDOWN_LOG_MODULE_METRICS_SUMMARY_CACHE_FILENAME = 'module-metrics-summary.json' -const ROLLDOWN_LOG_CACHE_VERSION = 2 +const ROLLDOWN_LOG_CACHE_VERSION = 3 type ManagerSnapshot = ReturnType diff --git a/packages/rolldown/src/node/rolldown/logs-manager.ts b/packages/rolldown/src/node/rolldown/logs-manager.ts index 12dec743f..e9ad483a7 100644 --- a/packages/rolldown/src/node/rolldown/logs-manager.ts +++ b/packages/rolldown/src/node/rolldown/logs-manager.ts @@ -20,6 +20,28 @@ function assertValidSessionId(id: string): void { } } +interface SessionListCacheEntry { + fingerprint: string + sessions: BuildInfo[] +} + +// Parsed session metadata, keyed by the sessions directory. Invalidated when +// a session's meta.json or the alias file changes; directory names cannot contain NUL. +const sessionListCache = new Map() + +function copySessions(sessions: BuildInfo[]): BuildInfo[] { + return sessions.map(session => ({ ...session })) +} + +async function fileMtimeMs(path: string): Promise { + try { + return (await fs.stat(path)).mtimeMs + } + catch { + return 0 + } +} + export class RolldownLogsManager { constructor( readonly dir: string, @@ -48,14 +70,28 @@ export class RolldownLogsManager { await fs.writeFile(this.aliasesPath, `${JSON.stringify(aliases, null, 2)}\n`, 'utf-8') } + private async sessionListFingerprint(): Promise { + const [entries, aliasesMtimeMs] = await Promise.all([ + fs.readdir(this.dir, { withFileTypes: true }), + fileMtimeMs(this.aliasesPath), + ]) + const directories = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort() + const metaMtimes = await Promise.all(directories.map(name => fileMtimeMs(join(this.dir, name, 'meta.json')))) + return `${aliasesMtimeMs}\0${directories.map((name, index) => `${name}\0${metaMtimes[index]}`).join('\0')}` + } + async list() { if (!existsSync(this.dir)) { + sessionListCache.delete(this.dir) return [] } - const [sessions, aliases] = await Promise.all([ - fs.readdir(this.dir, { withFileTypes: true }), - this.readAliases(), - ]) + const fingerprint = await this.sessionListFingerprint() + const cached = sessionListCache.get(this.dir) + if (cached?.fingerprint === fingerprint) + return copySessions(cached.sessions) + + const aliases = await this.readAliases() + const sessions = await fs.readdir(this.dir, { withFileTypes: true }) const entries = await Promise.all(sessions .filter(d => d.isDirectory()) .filter(d => existsSync(join(this.dir, d.name, 'meta.json'))) @@ -75,7 +111,11 @@ export class RolldownLogsManager { } }), ) - return entries.filter((entry): entry is BuildInfo => !!entry) + const listed = entries.filter((entry): entry is BuildInfo => !!entry) + const fingerprintAfter = await this.sessionListFingerprint() + if (fingerprintAfter === fingerprint) + sessionListCache.set(this.dir, { fingerprint, sessions: listed }) + return copySessions(listed) } /** Set or clear a user-defined alias for a session. */ diff --git a/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts b/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts index 51820a8ee..7d3dbcb6b 100644 --- a/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts +++ b/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts @@ -397,6 +397,36 @@ async function comparePlugins(previous: SessionCompareSource, current: SessionCo })) } +export async function createSessionCompareDetails( + previousReader: RolldownEventsReader, + currentReader: RolldownEventsReader, +): Promise { + const previous = createCompareSource(previousReader) + const current = createCompareSource(currentReader) + + const assets = compareAssets(previous, current) + const chunks = compareChunks(previous, current) + const packages = comparePackages(previous, current) + const plugins = await comparePlugins(previous, current) + + return { + sessionStats: { + previous: { + packages: previous.packages.length, + duplicatedPackages: previous.packages.filter(pkg => pkg.duplicated).length, + }, + current: { + packages: current.packages.length, + duplicatedPackages: current.packages.filter(pkg => pkg.duplicated).length, + }, + }, + assets, + chunks, + packages, + plugins, + } +} + export const rolldownGetSessionCompareDetails = defineRpcFunction({ name: 'vite:rolldown:get-session-compare-details', type: 'query', @@ -410,30 +440,7 @@ export const rolldownGetSessionCompareDetails = defineRpcFunction({ manager.loadAssetSession(previousSession!), manager.loadAssetSession(currentSession!), ]) - const previous = createCompareSource(previousReader) - const current = createCompareSource(currentReader) - - const assets = compareAssets(previous, current) - const chunks = compareChunks(previous, current) - const packages = comparePackages(previous, current) - const plugins = await comparePlugins(previous, current) - - return { - sessionStats: { - previous: { - packages: previous.packages.length, - duplicatedPackages: previous.packages.filter(pkg => pkg.duplicated).length, - }, - current: { - packages: current.packages.length, - duplicatedPackages: current.packages.filter(pkg => pkg.duplicated).length, - }, - }, - assets, - chunks, - packages, - plugins, - } + return createSessionCompareDetails(previousReader, currentReader) }, } }, diff --git a/packages/rolldown/src/node/utils/__tests__/chunk.test.ts b/packages/rolldown/src/node/utils/__tests__/chunk.test.ts new file mode 100644 index 000000000..a73a6c2af --- /dev/null +++ b/packages/rolldown/src/node/utils/__tests__/chunk.test.ts @@ -0,0 +1,29 @@ +import type { RolldownChunkInfo } from '../../../shared/types' +import { describe, expect, it } from 'vitest' +import { getInitialChunkIds } from '../chunk' + +function chunk(chunk_id: number, imports: [number, string][] = [], is_user_defined_entry = false) { + return { chunk_id, is_user_defined_entry, imports: imports.map(([chunk_id, kind]) => ({ chunk_id, kind })) } as unknown as RolldownChunkInfo +} + +describe('getInitialChunkIds', () => { + it('follows static imports from user-defined entries and stops at dynamic imports', () => { + const ids = getInitialChunkIds([ + chunk(0, [[1, 'import-statement'], [2, 'dynamic-import']], true), + chunk(1, [[3, 'import-statement']]), + chunk(2, [[4, 'import-statement']]), + chunk(3), + chunk(4), + ]) + expect([...ids].sort()).toEqual([0, 1, 3]) + }) + + it('keeps a chunk initial when it is reachable both statically and dynamically', () => { + const ids = getInitialChunkIds([ + chunk(0, [[1, 'dynamic-import'], [2, 'import-statement']], true), + chunk(1, [[2, 'import-statement']]), + chunk(2, [[1, 'import-statement']]), + ]) + expect([...ids].sort()).toEqual([0, 1, 2]) + }) +}) diff --git a/packages/rolldown/src/node/utils/chunk.ts b/packages/rolldown/src/node/utils/chunk.ts index e2fe56be0..a4396a00b 100644 --- a/packages/rolldown/src/node/utils/chunk.ts +++ b/packages/rolldown/src/node/utils/chunk.ts @@ -1,6 +1,7 @@ import type { RolldownChunkInfo } from '../../shared/types' -// initial chunks = user-defined entry chunk (initial entry chunk) + user-defined entry chunk's imports (initial common chunk) +// initial chunks = user-defined entry chunk (initial entry chunk) + the chunks it statically imports (initial common chunk). +// Dynamic imports load on demand, so the chunks behind them stay async. export function getInitialChunkIds(chunks: RolldownChunkInfo[]) { const chunkMap = new Map(chunks.map(chunk => [chunk.chunk_id, chunk])) const entryChunkIds = chunks.filter(chunk => !!chunk.is_user_defined_entry).map(chunk => chunk.chunk_id) @@ -18,6 +19,8 @@ export function getInitialChunkIds(chunks: RolldownChunkInfo[]) { const chunk = chunkMap.get(chunkId) if (chunk?.imports) { for (const _import of chunk.imports) { + if (_import.kind === 'dynamic-import') + continue if (!initialChunkIds.has(_import.chunk_id)) { initialChunkIds.add(_import.chunk_id) queue.push(_import.chunk_id) diff --git a/packages/rolldown/tsdown.config.ts b/packages/rolldown/tsdown.config.ts index 7c3f1c2d4..234b3460e 100644 --- a/packages/rolldown/tsdown.config.ts +++ b/packages/rolldown/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: { index: 'src/index.ts', + node: 'src/node/api.ts', dirs: 'src/dirs.ts', }, tsconfig: '../../tsconfig.base.json', diff --git a/playgrounds/production-nuxt/pnpm-workspace.yaml b/playgrounds/production-nuxt/pnpm-workspace.yaml index 92b509e66..0428b26a9 100644 --- a/playgrounds/production-nuxt/pnpm-workspace.yaml +++ b/playgrounds/production-nuxt/pnpm-workspace.yaml @@ -20,6 +20,7 @@ overrides: '@vitejs/devtools-vite': 'file:.tarballs/vitejs-devtools-vite.tgz' '@vitejs/devtools-vitest': 'file:.tarballs/vitejs-devtools-vitest.tgz' '@vitejs/devtools-oxc': 'file:.tarballs/vitejs-devtools-oxc.tgz' + '@vitejs/devtools-agent': 'file:.tarballs/vitejs-devtools-agent.tgz' verifyDepsBeforeRun: false allowBuilds: diff --git a/playgrounds/production/pnpm-workspace.yaml b/playgrounds/production/pnpm-workspace.yaml index a01b2017d..1592a2ca3 100644 --- a/playgrounds/production/pnpm-workspace.yaml +++ b/playgrounds/production/pnpm-workspace.yaml @@ -18,6 +18,7 @@ overrides: '@vitejs/devtools-vite': 'file:.tarballs/vitejs-devtools-vite.tgz' '@vitejs/devtools-vitest': 'file:.tarballs/vitejs-devtools-vitest.tgz' '@vitejs/devtools-oxc': 'file:.tarballs/vitejs-devtools-oxc.tgz' + '@vitejs/devtools-agent': 'file:.tarballs/vitejs-devtools-agent.tgz' verifyDepsBeforeRun: false allowBuilds: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af4efb65b..24319d585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ catalogs: webext-bridge: specifier: ^6.0.1 version: 6.0.1 + zod: + specifier: ^4.6.5 + version: 4.6.5 devtools: '@antfu/eslint-config': specifier: ^9.5.1 @@ -945,6 +948,34 @@ importers: specifier: ^8.3.4 version: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@packages+core)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + packages/agent: + dependencies: + '@vitejs/devtools-kit': + specifier: workspace:* + version: link:../kit + local-pkg: + specifier: catalog:deps + version: 1.2.1 + nostics: + specifier: catalog:deps + version: 1.3.0 + zod: + specifier: catalog:deps + version: 4.6.5 + devDependencies: + '@vitejs/devtools-rolldown': + specifier: workspace:* + version: link:../rolldown + '@vitejs/devtools-vite': + specifier: workspace:* + version: link:../vite + tsdown: + specifier: catalog:build + version: 0.23.1(@vitejs/devtools@0.8.0)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.13)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.13(typescript@6.0.3)) + typescript: + specifier: catalog:devtools + version: 6.0.3 + packages/core: dependencies: '@devframes/agentic': @@ -1002,6 +1033,9 @@ importers: '@clack/prompts': specifier: catalog:inlined version: 1.8.1 + '@vitejs/devtools-agent': + specifier: workspace:* + version: link:../agent '@vitejs/devtools-rolldown': specifier: workspace:* version: link:../rolldown @@ -3555,10 +3589,6 @@ packages: cpu: [x64] os: [win32] - '@oxlint-config-inspector/core@1.1.5': - resolution: {integrity: sha512-5zosw/i19IxmuVI2q3tlbOTe2xbs+LIc8kDdsDWYEdxL3dKCK9XwzyBAcQ8cxQZZ7OBQ231qD0C/91PQFqg8fg==} - engines: {node: '>=22'} - '@oxlint-config-inspector/core@1.1.6': resolution: {integrity: sha512-H+L5QVizpJ95yCUJ4kN0Py+a62d+iU4P4dgDEWUxphpoEiBJ9Pw/Kf8RK/z6KELNSKjyqracZmRyrfdn6fuu9w==} engines: {node: '>=22'} @@ -4921,10 +4951,19 @@ packages: peerDependencies: vite: ^8.3.4 + '@vitejs/devtools-kit@0.8.0': + resolution: {integrity: sha512-R4bXaPpKXbNY2sUuJMhnsSSgHkxOorkMa1gFI6I8ISSARW6TjXbj+GZK6JRdmn9TTn/kKMCGUhREHr/UcHXLAw==} + peerDependencies: + vite: ^8.3.4 + '@vitejs/devtools-oxc@0.7.6': resolution: {integrity: sha512-B0cHMS4ysDvQR2qwskn02jmU5rKcrL+kZg4isA7fZJJeIO2p5dK1hVz9qRyYRz/qGjvpbjut5fJ5SuDp65V0Tw==} hasBin: true + '@vitejs/devtools-oxc@0.8.0': + resolution: {integrity: sha512-8E/H+pAZi0feAElbOljrhMdkvTsy/yHQKM+ReoB7b5m5dMemV8nG9KuEJde1mVbhTzvTtApegdWbY5ArhsEsBA==} + hasBin: true + '@vitejs/devtools-rolldown@0.7.6': resolution: {integrity: sha512-uoxchaiha0U2s6mbBFx5dJmNztMgPmjdii+9NryN/YNQnq1uMoOEZ8ym5ui6niB8kbC9wTQrU5aw2yKr5atSdg==} @@ -4941,6 +4980,14 @@ packages: vitest: optional: true + '@vitejs/devtools-vitest@0.8.0': + resolution: {integrity: sha512-PEXqvxO4EX3VN9nQz4+DkLEEL+t5verzwFFCf5pUFD3b8MD1Z/5oN//9WSVQdXr54E/AsMmNvadiYf25N9DzeQ==} + peerDependencies: + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true + '@vitejs/devtools@0.7.6': resolution: {integrity: sha512-ys4jy3cP+OibJRyM7cKXJPukb89Q0MbYUVAWjTyQlaboEi4EjOwAkbh9467Dk+ycMH3F/B9DrmXJAMBT4504xA==} hasBin: true @@ -4960,6 +5007,25 @@ packages: '@vitejs/devtools-vitest': optional: true + '@vitejs/devtools@0.8.0': + resolution: {integrity: sha512-4HyRoreQ3lmIsvq5IGadtllHzzv4tCpEaT7MpnClWg4TXd3niytbRdGW/+sNg7qefDoPcGCWbpcd2Ts1EN2/NQ==} + hasBin: true + peerDependencies: + '@vitejs/devtools-oxc': ^0.8.0 + '@vitejs/devtools-rolldown': ^0.8.0 + '@vitejs/devtools-vite': ^0.8.0 + '@vitejs/devtools-vitest': ^0.8.0 + vite: ^8.3.4 + peerDependenciesMeta: + '@vitejs/devtools-oxc': + optional: true + '@vitejs/devtools-rolldown': + optional: true + '@vitejs/devtools-vite': + optional: true + '@vitejs/devtools-vitest': + optional: true + '@vitejs/plugin-vue-jsx@5.1.6': resolution: {integrity: sha512-YXvi4as2clxt6DFw5+a0tTA97ntiQXm/raR8ofNj3aNwwdlVGTiG2gp7EvfZW17P50acL/9bP0ccF4XnqNmlgA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10589,7 +10655,7 @@ snapshots: '@modelcontextprotocol/client': 2.3.1 '@modelcontextprotocol/server': 2.3.1 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@0.11.22) - h3: 2.0.2(crossws@0.4.12(srvx@0.11.22)) + h3: 2.0.5(crossws@0.4.12(srvx@0.11.22)) pathe: 2.0.3 transitivePeerDependencies: - crossws @@ -10600,7 +10666,7 @@ snapshots: '@modelcontextprotocol/client': 2.3.1 '@modelcontextprotocol/server': 2.3.1 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) - h3: 2.0.2(crossws@0.4.12(srvx@1.0.5)) + h3: 2.0.5(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 transitivePeerDependencies: - crossws @@ -10631,7 +10697,7 @@ snapshots: dependencies: destr: 2.0.5 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@0.11.22) - h3: 2.0.2(crossws@0.4.12(srvx@0.11.22)) + h3: 2.0.5(crossws@0.4.12(srvx@0.11.22)) pathe: 2.0.3 tinyexec: 1.3.1 zigpty: 0.2.1 @@ -10645,7 +10711,7 @@ snapshots: dependencies: destr: 2.0.5 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) - h3: 2.0.2(crossws@0.4.12(srvx@1.0.5)) + h3: 2.0.5(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 tinyexec: 1.3.1 zigpty: 0.2.1 @@ -10765,7 +10831,7 @@ snapshots: cac: 7.1.0 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) optionalDependencies: - vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) '@devframes/plugin-og@1.2.6(devframe@1.2.6)(vite@8.3.4)': dependencies: @@ -10806,7 +10872,7 @@ snapshots: devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) zigpty: 0.2.1 optionalDependencies: - vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: - '@devframes/hub' - '@devframes/hub-ui' @@ -10850,7 +10916,7 @@ snapshots: optionalDependencies: '@devframes/hub': 1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6) '@devframes/hub-ui': 1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(@devframes/json-render@1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6))(devframe@1.2.6) - vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) '@docsearch/css@4.7.0': {} @@ -12845,14 +12911,6 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.73.0': optional: true - '@oxlint-config-inspector/core@1.1.5': - dependencies: - cosmiconfig: 10.0.1 - jiti: 2.7.0 - jsonc-parser: 3.3.1 - tinyexec: 1.3.1 - optional: true - '@oxlint-config-inspector/core@1.1.6': dependencies: cosmiconfig: 10.0.1 @@ -14419,9 +14477,25 @@ snapshots: - ocache - srvx + '@vitejs/devtools-kit@0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)': + dependencies: + '@devframes/hub': 1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6) + devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) + local-pkg: 1.2.1 + nostics: 1.3.0 + package-manager-detector: 1.9.0 + vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + transitivePeerDependencies: + - '@devframes/agentic' + - cac + - crossws + - ocache + - srvx + optional: true + '@vitejs/devtools-oxc@0.7.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@0.11.22))(srvx@0.11.22)(vite@8.3.4)': dependencies: - '@oxlint-config-inspector/core': 1.1.5 + '@oxlint-config-inspector/core': 1.1.6 '@vitejs/devtools-kit': 0.7.6(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@0.11.22))(srvx@0.11.22)(vite@8.3.4) cac: 7.1.0 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@0.11.22) @@ -14440,7 +14514,7 @@ snapshots: '@vitejs/devtools-oxc@0.7.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@0.11.22))(srvx@1.0.5)(vite@8.3.4)': dependencies: - '@oxlint-config-inspector/core': 1.1.5 + '@oxlint-config-inspector/core': 1.1.6 '@vitejs/devtools-kit': 0.7.6(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@0.11.22))(srvx@1.0.5)(vite@8.3.4) cac: 7.1.0 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) @@ -14458,7 +14532,7 @@ snapshots: '@vitejs/devtools-oxc@0.7.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)': dependencies: - '@oxlint-config-inspector/core': 1.1.5 + '@oxlint-config-inspector/core': 1.1.6 '@vitejs/devtools-kit': 0.7.6(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) cac: 7.1.0 devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) @@ -14475,6 +14549,25 @@ snapshots: - vite optional: true + '@vitejs/devtools-oxc@0.8.0(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)': + dependencies: + '@oxlint-config-inspector/core': 1.1.6 + '@vitejs/devtools-kit': 0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + cac: 7.1.0 + devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) + local-pkg: 1.2.1 + nostics: 1.3.0 + package-manager-detector: 1.9.0 + pathe: 2.0.3 + tinyexec: 1.3.1 + transitivePeerDependencies: + - '@devframes/agentic' + - crossws + - ocache + - srvx + - vite + optional: true + '@vitejs/devtools-rolldown@0.7.6(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@0.11.22))(srvx@0.11.22)(vite@8.3.4)': dependencies: '@rolldown/debug': 1.2.14 @@ -14625,6 +14718,25 @@ snapshots: - vite optional: true + '@vitejs/devtools-vitest@0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)(vitest@5.0.5)': + dependencies: + '@vitejs/devtools-kit': 0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) + local-pkg: 1.2.1 + nostics: 1.3.0 + package-manager-detector: 1.9.0 + tinyglobby: 0.2.17 + optionalDependencies: + vitest: 5.0.5(@types/node@26.5.1)(vite@8.3.4) + transitivePeerDependencies: + - '@devframes/agentic' + - cac + - crossws + - ocache + - srvx + - vite + optional: true + '@vitejs/devtools@0.7.6(@devframes/json-render@1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@0.11.22))(devframe@1.2.6))(devframe@1.2.6))(@vitejs/devtools-oxc@0.7.6)(@vitejs/devtools-rolldown@0.7.6)(@vitejs/devtools-vite@0.7.6)(@vitejs/devtools-vitest@0.7.6)(crossws@0.4.12(srvx@0.11.22))(srvx@0.11.22)(vite@8.3.4)': dependencies: '@devframes/agentic': 1.2.6(crossws@0.4.12(srvx@0.11.22))(devframe@1.2.6) @@ -14753,6 +14865,39 @@ snapshots: - ocache - srvx + '@vitejs/devtools@0.8.0(@vitejs/devtools-oxc@0.8.0)(@vitejs/devtools-rolldown@packages+rolldown)(@vitejs/devtools-vite@packages+vite)(@vitejs/devtools-vitest@0.8.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)': + dependencies: + '@devframes/agentic': 1.2.6(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6) + '@devframes/hub': 1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6) + '@devframes/hub-ui': 1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(@devframes/json-render@1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6))(devframe@1.2.6) + '@devframes/json-render': 1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6) + '@devframes/json-render-ui': 1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6) + '@devframes/plugin-inspect': 1.2.6(devframe@1.2.6)(vite@8.3.4) + '@devframes/plugin-messages': 1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6)(vite@8.3.4) + '@devframes/plugin-terminals': 1.2.6(@devframes/hub-ui@1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(@devframes/json-render@1.2.6(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6))(devframe@1.2.6))(@devframes/hub@1.2.6(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.6))(devframe@1.2.6)(vite@8.3.4) + '@vitejs/devtools-kit': 0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + cac: 7.1.0 + devframe: 1.2.6(@devframes/agentic@1.2.6)(cac@7.1.0)(srvx@1.0.5) + h3: 2.0.5(crossws@0.4.12(srvx@1.0.5)) + local-pkg: 1.2.1 + nostics: 1.3.0 + obug: 3.0.0 + pathe: 2.0.3 + vite: 8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) + optionalDependencies: + '@vitejs/devtools-oxc': 0.8.0(@devframes/agentic@1.2.6)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + '@vitejs/devtools-rolldown': link:packages/rolldown + '@vitejs/devtools-vite': link:packages/vite + '@vitejs/devtools-vitest': 0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4)(vitest@5.0.5) + transitivePeerDependencies: + - '@devframes/plugin-inspect--assets' + - '@devframes/plugin-messages--assets' + - '@devframes/plugin-terminals--assets' + - crossws + - ocache + - srvx + optional: true + '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.3.4)(vue@3.5.44(typescript@6.0.3))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -17122,6 +17267,13 @@ snapshots: optionalDependencies: crossws: 0.4.12(srvx@1.0.5) + h3@2.0.5(crossws@0.4.12(srvx@0.11.22)): + dependencies: + rou3: 1.0.0 + srvx: 1.0.6 + optionalDependencies: + crossws: 0.4.12(srvx@0.11.22) + h3@2.0.5(crossws@0.4.12(srvx@1.0.5)): dependencies: rou3: 1.0.0 @@ -20535,6 +20687,34 @@ snapshots: - oxc-resolver - vue-tsc + tsdown@0.23.1(@vitejs/devtools@0.8.0)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.13)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.13(typescript@6.0.3)): + dependencies: + cac: 7.1.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 3.0.0 + picomatch: 4.0.7 + rolldown: 1.2.14 + rolldown-plugin-dts: 0.28.8(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.13)(oxc-resolver@11.24.2)(rolldown@1.2.14)(typescript@6.0.3)(vue-tsc@3.3.13(typescript@6.0.3)) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.5.0 + optionalDependencies: + '@vitejs/devtools': 0.8.0(@vitejs/devtools-oxc@0.8.0)(@vitejs/devtools-rolldown@packages+rolldown)(@vitejs/devtools-vite@packages+vite)(@vitejs/devtools-vitest@0.8.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + tsx: 4.23.15 + typescript: 6.0.3 + unrun: 0.3.1(synckit@0.11.13) + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - '@vue/language-core' + - oxc-resolver + - vue-tsc + tsdown@0.23.1(@vitejs/devtools@packages+core)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.13)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.13(typescript@6.0.3)): dependencies: cac: 7.1.0 @@ -21120,6 +21300,24 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 + vite@8.3.4(@types/node@26.5.1)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.5.1 + '@vitejs/devtools': 0.8.0(@vitejs/devtools-oxc@0.8.0)(@vitejs/devtools-rolldown@packages+rolldown)(@vitejs/devtools-vite@packages+vite)(@vitejs/devtools-vitest@0.8.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.4) + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.51.2 + tsx: 4.23.15 + yaml: 2.9.1 + optional: true + vite@8.3.4(@types/node@26.5.1)(@vitejs/devtools@packages+core)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb130d37c..e7c362ee9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -134,6 +134,7 @@ catalogs: unstorage: ^1.17.6 vue: ^3.5.44 webext-bridge: ^6.0.1 + zod: ^4.6.5 devtools: '@antfu/eslint-config': ^9.5.1 '@nuxt/devtools': *nuxt-devtools diff --git a/scripts/pack-devtools-tarballs.mjs b/scripts/pack-devtools-tarballs.mjs index 4c7ccfe72..7aab129f4 100644 --- a/scripts/pack-devtools-tarballs.mjs +++ b/scripts/pack-devtools-tarballs.mjs @@ -34,6 +34,7 @@ export const DEVTOOLS_PACKAGES = [ { name: '@vitejs/devtools-vite', dir: 'packages/vite', out: 'vitejs-devtools-vite.tgz' }, { name: '@vitejs/devtools-vitest', dir: 'packages/vitest', out: 'vitejs-devtools-vitest.tgz' }, { name: '@vitejs/devtools-oxc', dir: 'packages/oxc', out: 'vitejs-devtools-oxc.tgz' }, + { name: '@vitejs/devtools-agent', dir: 'packages/agent', out: 'vitejs-devtools-agent.tgz' }, ] /** @param {string} cmd @param {string} cwd */ diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.d.ts new file mode 100644 index 000000000..e2dc95752 --- /dev/null +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@vitejs/devtools-agent` + */ +// #region Functions +export declare function DevToolsAgent(): PluginWithDevTools; +// #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.js b/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.js new file mode 100644 index 000000000..84f0bc631 --- /dev/null +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-agent/index.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@vitejs/devtools-agent` + */ +// #region Functions +export function DevToolsAgent() {} +// #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.d.ts new file mode 100644 index 000000000..56b3b3a64 --- /dev/null +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.d.ts @@ -0,0 +1,218 @@ +/** + * Generated by tsnapi — public API snapshot of `@vitejs/devtools-rolldown/node` + */ +// #region Classes +export declare class RolldownEventsReader { + readonly filepath: string; + private readonly cacheKey; + lastBytes: number; + lastTimestamp: number; + manager: RolldownEventsManager; + meta: SessionMeta | undefined; + private pendingRead; + private pendingSummaryRead; + private lineNumber; + private moduleEventIndex; + private stringRefIndex; + private pendingHookCallStarts; + private moduleMetricsCache; + private moduleMetricsCacheBytes; + private pendingModuleMetrics; + private assetsReadyLocations; + private assetsHydrated; + private pendingPackageSummaryRead; + private packageSummaryTimestamp; + private summaryOnly; + private packageSummaryOnly; + private metricsSummaryOnly; + private logCache; + private constructor(); + static get(_: string, _?: string): RolldownEventsReader; + static peek(_: string, _?: string): RolldownEventsReader | undefined; + read(): Promise; + readSummary(): Promise; + private readChanges; + private readSummaryChanges; + private readEventLines; + readAssets(): Promise; + private recordAssetsReadyLocation; + readPackageSummary(): Promise; + readPluginBuildMetricsSummary(): Promise>; + ensurePackageSummaryCache(): Promise; + private readPackageSummaryChanges; + private recordPackageSummarySkippedLine; + private recordPackageTransformEnd; + private recordPackageTransformSize; + private getModuleEventIndex; + private indexEvent; + private readEventsAt; + private readStringRefs; + readModuleBuildMetrics(_: string): Promise; + hydratePluginBuildMetrics(_: number): Promise; + private clearModuleMetricsCache; + private setCachedModuleBuildMetrics; + private readIndexedHookEvents; + private hydrateModuleBuildMetricsFromIndex; + private hydratePluginBuildMetricsFromIndex; + private hydrateModuleBuildMetrics; + hasPendingRead(): boolean; + hasReadData(): boolean; + private createLogCacheState; + hasCompleteSession(): boolean; + isReadingCompleteSession(): boolean; + dispose(): void; + private resetReadState; + private disposeData; + [Symbol.dispose](): void; +} +// #endregion + +// #region Functions +export declare function createSessionCompareDetails(_: RolldownEventsReader, _: RolldownEventsReader): Promise; +export declare function getLogsManager(_: ViteDevToolsNodeContext): RolldownLogsManager; +export declare function getPackageMeta(_: RolldownEventsReader): PackageMeta; +// #endregion + +// #region Referenced (internal) +interface ModuleBuildMetrics { + resolve_ids: RolldownResolveInfo[]; + loads: RolldownModuleLoadInfo[]; + transforms: Array & { + source_code_size: number; + transformed_code_size: number; + }>; +} +interface PackageMeta { + isSupported: boolean; + packages: PackageInfo$1[]; +} +interface PluginBuildMetricsSummary { + plugin_id: number; + plugin_name: string; + total: PluginMetricSummary; + resolve: PluginMetricSummary; + load: PluginMetricSummary; + transform: PluginMetricSummary; +} +declare class RolldownEventsManager { + eventCount: number; + lastEvent: RolldownEvent | undefined; + chunks: Map; + assets: Map; + chunkAssetMap: Map; + modules: Map; + packageGraphReady: boolean; + packages: Map; + source_refs: Map; + module_build_hook_events: Map; + module_build_metrics: Map; + plugin_build_metrics: Map; + build_start_time: number; + build_end_time: number; + getEventContentInfo(_: any, _: 'content'): ContentInfo; + recordBuildMetrics(_: ModuleBuildHookEvents): void; + handleEvent(_: Event): ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").SessionMeta & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookTransformCallStart & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookTransformCallEnd & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookLoadCallStart & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookLoadCallEnd & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").BuildStart & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").BuildEnd & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookResolveIdCallStart & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & HookResolveIdCallEnd & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").ModuleGraphReady & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").HookRenderChunkStart & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").HookRenderChunkEnd & { + event_id: string; + }) | ({ + timestamp: string; + session_id: string; + } & import("@rolldown/debug").AssetsReady & { + event_id: string; + }) | undefined; + dispose(): void; + snapshot(): RolldownEventsManagerSnapshot; + restore(_: RolldownEventsManagerSnapshot): void; + [Symbol.dispose](): void; +} +declare class RolldownLogsManager { + readonly dir: string; + constructor(_: string); + private get aliasesPath(); + readAliases(): Promise>; + private writeAliases; + private sessionListFingerprint; + list(): Promise; + renameSession(_: string, _: string): Promise; + deleteSession(_: string): Promise; + loadSession(_: string): Promise; + loadSessionSummary(_: string): Promise; + loadAssetSession(_: string): Promise; + loadPackageSession(_: string): Promise; +} +interface SessionCompareDetails { + sessionStats: { + previous: { + packages: number; + duplicatedPackages: number; + }; + current: { + packages: number; + duplicatedPackages: number; + }; + }; + assets: SessionCompareAssetDiff[]; + chunks: SessionCompareChunkDiff[]; + packages: SessionComparePackageDiff[]; + plugins: SessionComparePluginDiff[]; +} +// #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.js b/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.js new file mode 100644 index 000000000..50f81a66a --- /dev/null +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-rolldown/node.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@vitejs/devtools-rolldown/node` + */ +// #region Functions +export async function createSessionCompareDetails(_, _) {} +export function getLogsManager(_) {} +export function getPackageMeta(_) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index dbd8c1b04..6c2bfc725 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,6 +33,12 @@ "@vitejs/devtools-kit": [ "./packages/kit/src/index.ts" ], + "@vitejs/devtools-agent": [ + "./packages/agent/src/index.ts" + ], + "@vitejs/devtools-rolldown/node": [ + "./packages/rolldown/src/node/api.ts" + ], "@vitejs/devtools-rolldown": [ "./packages/rolldown/src/index.ts" ], diff --git a/turbo.json b/turbo.json index 550a36673..7b678c9ff 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,11 @@ "agentGuidance": false, "globalDependencies": ["pnpm-lock.yaml"], "tasks": { + "@vitejs/devtools-agent#build": { + "outputLogs": "new-only", + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, "@vitejs/devtools#build": { "outputLogs": "new-only", "dependsOn": ["^build"],