Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/content/1.guide/0.getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ Restart your Nuxt server and open your app in browser. Click the Nuxt icon on th

### Opting in to v4.0

Nuxt DevTools v4.0 is currently in alpha. Since Nuxt ships with a built-in version of DevTools, you can opt-in to v4.0 by using package manager resolutions to override the bundled version:
Nuxt DevTools v4.0 is currently in alpha and requires Vite 8 — it integrates
with [Vite DevTools](https://github.com/vitejs/devtools), whose Code Server
dock only peers with Vite 8. Since Nuxt ships with a built-in version of
DevTools, you can opt-in to v4.0 by using package manager resolutions to
override the bundled version:

::code-group

Expand Down
22 changes: 22 additions & 0 deletions docs/content/2.module/1.utils-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ We recommend module authors to install `@nuxt/devtools-kit` as a dependency and

## `@nuxt/devtools-kit`

### Joining the `Nuxt` Dock Group

Vite DevTools registers a public `Nuxt` dock group (id `nuxt`) on the
framework category, with `nuxt:devtools` as its default member. Module
authors join the group natively by pointing their own dock entries at it —
no special Nuxt API required:

```ts
import { onDevtoolsReady } from '@nuxt/devtools-kit'

onDevtoolsReady((ctx) => {
ctx.docks.register({
id: 'my-module',
type: 'iframe',
title: 'My Module',
icon: 'i-ph-puzzle-piece',
url: '/my-module/',
groupId: 'nuxt',
})
})
```

### `addCustomTab()`

::warning
Expand Down
22 changes: 21 additions & 1 deletion docs/content/2.module/3.migration-v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,27 @@ The `WizardFunctions`, `WizardActions`, and `GetWizardArgs` types have been remo

## Vite DevTools Integration is Now Always Enabled

The `viteDevTools` module option has been removed. Nuxt DevTools now always integrates with [Vite DevTools](https://github.com/vitejs/devtools) as a dock entry. The built-in floating panel has been removed — DevTools is accessed through the Vite DevTools panel instead.
The `viteDevTools` module option has been removed. Nuxt DevTools now always integrates with [Vite DevTools](https://github.com/vitejs/devtools) as a dock entry, nested under a `Nuxt` framework group. The built-in floating panel has been removed — DevTools is accessed through the Vite DevTools panel instead.

`@nuxt/devtools` now requires Vite 8 (`peerDependencies.vite` narrowed from
`>=6.0` to `^8.0.14`), matching the Vite DevTools plugins it depends on. Join
the `Nuxt` group from your own module by pointing a dock entry's `groupId` at
`'nuxt'`:

```ts
import { onDevtoolsReady } from '@nuxt/devtools-kit'

onDevtoolsReady((ctx) => {
ctx.docks.register({
id: 'my-module',
type: 'iframe',
title: 'My Module',
icon: 'i-ph-puzzle-piece',
url: '/my-module/',
groupId: 'nuxt',
})
})
```

```diff
export default defineNuxtConfig({
Expand Down
23 changes: 23 additions & 0 deletions packages/devtools-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ import { deprecate } from './diagnostics'

export * from './diagnostics'

/**
* The public `Nuxt` dock group id registered on the Vite DevTools framework
* category. Module authors join the group natively by pointing their own
* dock entries at it — no special Nuxt API required:
*
* @example
* ```ts
* import { NUXT_DEVTOOLS_GROUP_ID, onDevtoolsReady } from '@nuxt/devtools-kit'
*
* onDevtoolsReady((ctx) => {
* ctx.docks.register({
* id: 'my-module',
* type: 'iframe',
* title: 'My Module',
* icon: 'i-ph-puzzle-piece',
* url: '/my-module/',
* groupId: NUXT_DEVTOOLS_GROUP_ID,
* })
* })
* ```
*/
export const NUXT_DEVTOOLS_GROUP_ID = 'nuxt'

/**
* Hooks to extend a custom tab in devtools.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"prepack": "pnpm build"
},
"peerDependencies": {
"vite": ">=6.0"
"vite": "^8.0.14"
},
"dependencies": {
"@nuxt/devtools-kit": "workspace:*",
Expand Down
35 changes: 27 additions & 8 deletions packages/devtools/src/module-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ModuleOptions, NuxtDevToolsOptions } from './types'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import { NUXT_DEVTOOLS_GROUP_ID } from '@nuxt/devtools-kit'
import { addImports, addPlugin, addTemplate, addVitePlugin, extendViteConfig, logger } from '@nuxt/kit'
import { colors } from 'consola/utils'
import { join } from 'pathe'
Expand All @@ -15,6 +16,7 @@ import { version } from '../package.json'
import { createDefaultTabOptions, setServerTasksEnabledByDefault } from './constant'
import { clientDir, packageDir, runtimeDir } from './dirs'
import { setupRPC } from './server-rpc'
import { skipInSSR } from './server-rpc/skip-in-ssr'
import { readLocalOptions } from './utils/local-options'

const MULTIPLE_SLASHES_RE = /\/+/g
Expand Down Expand Up @@ -96,14 +98,31 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) {
name: 'nuxt:devtools',
devtools: {
async setup(ctx) {
ctx.docks.register({
id: 'nuxt:devtools',
type: 'iframe',
icon: '/__nuxt_devtools__/client/nuxt.svg',
title: 'Nuxt DevTools',
url: '/__nuxt_devtools__/client/',
defaultOrder: -2000,
})
// Only the browser-serving client Vite context registers the `Nuxt`
// group and its hub member — Nuxt's SSR Vite instance runs this same
// setup callback too, and would otherwise create a second, inert
// group + hub member. See `skipInSSR`.
if (!skipInSSR(ctx)) {
ctx.docks.register({
id: NUXT_DEVTOOLS_GROUP_ID,
type: 'group',
title: 'Nuxt',
icon: '/__nuxt_devtools__/client/nuxt.svg',
category: 'framework',
defaultOrder: -900,
defaultChildId: 'nuxt:devtools',
})

ctx.docks.register({
id: 'nuxt:devtools',
type: 'iframe',
icon: '/__nuxt_devtools__/client/nuxt.svg',
title: 'Nuxt DevTools',
url: '/__nuxt_devtools__/client/',
groupId: NUXT_DEVTOOLS_GROUP_ID,
defaultOrder: -300,
})
}

// Connect Nuxt DevTools to Vite DevTools Kit context
await connectDevToolsKit?.(ctx)
Expand Down
13 changes: 7 additions & 6 deletions packages/devtools/src/server-rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { setupOptionsRPC } from './options'
import { setupServerDataRPC } from './server-data'
import { setupServerRoutesRPC } from './server-routes'
import { setupServerTasksRPC } from './server-tasks'
import { skipInSSR } from './skip-in-ssr'
import { setupStorageRPC } from './storage'
import { setupTelemetryRPC } from './telemetry'
import { setupTerminalRPC } from './terminals'
Expand Down Expand Up @@ -163,15 +164,15 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
/**
* Connect to Vite DevTools Kit context.
* Called from the Vite DevTools plugin setup callback.
*
* Nuxt creates two Vite instances (client and SSR); only the browser-serving
* client instance has WebSocket peers, so we skip the SSR candidate (see
* `skipInSSR`) rather than relying on setup order.
*/
async function connectDevToolsKit(kitCtx: ViteDevToolsNodeContext) {
/**
* guarded to keep the first connection (client Vite), since Nuxt creates
* two Vite instances and the second (server) one has 0 WebSocket clients.
* If we don't guard this, the server connection will overwrite the client connection and break all RPC calls from server to client.
*/
if (devtoolsKitCtx)
if (devtoolsKitCtx || skipInSSR(kitCtx))
return

devtoolsKitCtx = kitCtx
const host = kitCtx.rpc

Expand Down
14 changes: 14 additions & 0 deletions packages/devtools/src/server-rpc/skip-in-ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit'

/**
* Whether a connecting `ViteDevToolsNodeContext` is Nuxt's SSR Vite instance,
* which should be skipped — only the browser-serving client instance should
* register docks and connect the DevTools kit RPC.
*
* Nuxt's Vite 8 client and SSR resolved configs both expose `client` and
* `ssr` environment names, so `config.environments` can't tell them apart.
* `config.build.ssr` can: it's only truthy on the SSR build.
*/
export function skipInSSR(ctx: Pick<ViteDevToolsNodeContext, 'viteConfig'> | undefined): boolean {
return Boolean(ctx?.viteConfig?.build?.ssr)
}
134 changes: 134 additions & 0 deletions packages/devtools/test/connect-devtools-kit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
import type { Nuxt } from 'nuxt/schema'
import { runWithNuxtContext } from '@nuxt/kit'
import { createHooks } from 'hookable'
import { describe, expect, it } from 'vitest'
import { setupRPC } from '../src/server-rpc'

/**
* A minimal `Nuxt` fixture: just enough `options` for every RPC sub-module
* `setupRPC()` wires up to read synchronously at setup time (they only
* register hooks / access `nuxt.options` — no filesystem or Vite server is
* touched by connecting a kit context in these tests).
*/
function fakeNuxt(): Nuxt {
const hooks = createHooks()
return {
options: {
rootDir: '/tmp/fixture-app',
srcDir: '/tmp/fixture-app',
dir: { public: 'public', app: 'app' },
app: { baseURL: '/' },
_layers: [],
analyzeDir: '/tmp/fixture-app/.nuxt/analyze',
runtimeConfig: {},
future: { compatibilityVersion: 4 },
_nuxtConfigFile: '/tmp/fixture-app/nuxt.config.ts',
build: {},
vite: {},
},
vfs: {},
hooks,
hook: hooks.hook.bind(hooks),
callHook: hooks.callHook.bind(hooks),
} as unknown as Nuxt
}

/** A fake connecting `ViteDevToolsNodeContext`: only what `connectDevToolsKit` reads. */
function fakeKitCtx(viteConfig: ViteDevToolsNodeContext['viteConfig']): ViteDevToolsNodeContext {
return {
viteConfig,
rpc: {
register: () => {},
has: () => false,
update: () => {},
broadcast: () => {},
},
} as unknown as ViteDevToolsNodeContext
}

const clientConfig = { command: 'serve', build: { ssr: false } } as ViteDevToolsNodeContext['viteConfig']
const ssrConfig = { command: 'build', build: { ssr: true } } as ViteDevToolsNodeContext['viteConfig']

function setup() {
const nuxt = fakeNuxt()
const { connectDevToolsKit } = runWithNuxtContext(nuxt, () => setupRPC(nuxt, {} as any))
const readyContexts: ViteDevToolsNodeContext[] = []
nuxt.hook('devtools:ready', (ctx) => {
readyContexts.push(ctx)
})
// `setupRPC` assigns the live server context (with a `devtoolsKit` getter)
// onto `nuxt.devtools` — read the connected kit context back through it.
const getConnectedKit = () => (nuxt as any).devtools.devtoolsKit as ViteDevToolsNodeContext | undefined
return { nuxt, connectDevToolsKit, getConnectedKit, readyContexts }
}

describe('connectDevToolsKit', () => {
it('connects the client candidate and fires devtools:ready once', async () => {
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const clientCtx = fakeKitCtx(clientConfig)

await connectDevToolsKit(clientCtx)

expect(readyContexts).toEqual([clientCtx])
expect(getConnectedKit()).toBe(clientCtx)
})

it('ignores the SSR candidate without connecting', async () => {
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const ssrCtx = fakeKitCtx(ssrConfig)

await connectDevToolsKit(ssrCtx)

expect(readyContexts).toEqual([])
expect(getConnectedKit()).toBeUndefined()
})

it('connects a candidate with a missing/falsy build.ssr, even outside "serve"', async () => {
// `skipInSSR` only reads `build.ssr` — any other candidate is treated as
// the client rather than logged and ignored.
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const ambiguousCtx = fakeKitCtx({ command: 'build', build: {} } as ViteDevToolsNodeContext['viteConfig'])

await connectDevToolsKit(ambiguousCtx)

expect(readyContexts).toEqual([ambiguousCtx])
expect(getConnectedKit()).toBe(ambiguousCtx)
})

it('connects the client candidate when the SSR candidate arrives first', async () => {
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const ssrCtx = fakeKitCtx(ssrConfig)
const clientCtx = fakeKitCtx(clientConfig)

await connectDevToolsKit(ssrCtx)
await connectDevToolsKit(clientCtx)

expect(readyContexts).toEqual([clientCtx])
expect(getConnectedKit()).toBe(clientCtx)
})

it('connects the client candidate when the client candidate arrives first', async () => {
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const ssrCtx = fakeKitCtx(ssrConfig)
const clientCtx = fakeKitCtx(clientConfig)

await connectDevToolsKit(clientCtx)
await connectDevToolsKit(ssrCtx)

expect(readyContexts).toEqual([clientCtx])
expect(getConnectedKit()).toBe(clientCtx)
})

it('ignores a second, repeated client candidate once connected', async () => {
const { connectDevToolsKit, getConnectedKit, readyContexts } = setup()
const firstClientCtx = fakeKitCtx(clientConfig)
const secondClientCtx = fakeKitCtx(clientConfig)

await connectDevToolsKit(firstClientCtx)
await connectDevToolsKit(secondClientCtx)

expect(readyContexts).toEqual([firstClientCtx])
expect(getConnectedKit()).toBe(firstClientCtx)
})
})
41 changes: 41 additions & 0 deletions packages/devtools/test/skip-in-ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
import { describe, expect, it } from 'vitest'
import { skipInSSR } from '../src/server-rpc/skip-in-ssr'

function fakeCtx(viteConfig: Partial<ViteDevToolsNodeContext['viteConfig']> | undefined): Pick<ViteDevToolsNodeContext, 'viteConfig'> {
return { viteConfig } as Pick<ViteDevToolsNodeContext, 'viteConfig'>
}

describe('skipInSSR', () => {
it('skips a truthy build.ssr, regardless of command', () => {
expect(skipInSSR(fakeCtx({ command: 'build', build: { ssr: true } as any }))).toBe(true)
expect(skipInSSR(fakeCtx({ command: 'serve', build: { ssr: true } as any }))).toBe(true)
})

it('does not skip a falsy build.ssr', () => {
expect(skipInSSR(fakeCtx({ command: 'serve', build: { ssr: false } as any }))).toBe(false)
expect(skipInSSR(fakeCtx({ command: 'serve', build: {} as any }))).toBe(false)
})

it('does not classify from environment names', () => {
// Vite 8 exposes `client` and `ssr` environment names on both the
// Nuxt client and SSR resolved configs, so they must not be consulted.
const bothEnvsSsr = fakeCtx({
command: 'build',
build: { ssr: true } as any,
environments: { client: {}, ssr: {} } as any,
})
const bothEnvsClient = fakeCtx({
command: 'serve',
build: {} as any,
environments: { client: {}, ssr: {} } as any,
})
expect(skipInSSR(bothEnvsSsr)).toBe(true)
expect(skipInSSR(bothEnvsClient)).toBe(false)
})

it('does not skip a missing config', () => {
expect(skipInSSR(undefined)).toBe(false)
expect(skipInSSR(fakeCtx(undefined))).toBe(false)
})
})
2 changes: 1 addition & 1 deletion tests/e2e/fixtures/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const test = base.extend<DevToolsFixtures>({
null,
{ timeout: 30_000 },
)
// devframe 0.6 only fetches the `devframe:docks` shared state once the
// Devframe 0.7 only fetches the `devframe:docks` shared state once the
// client is marked trusted (via the `rpc:is-trusted:updated` event).
// `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` trusts the *server* peer — so RPC
// calls are allowed — but never flips the *client-side* trust flag, so the
Expand Down
Loading
Loading