From 990152aab1065b198a2e4326d6d4c6c082f8d6a3 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 03:47:20 +0000 Subject: [PATCH 1/2] feat(devtools): select client Vite context deterministically, add Nuxt dock group Replace the first-connection-wins guard in connectDevToolsKit() with a Vite 8 classifier (classifyViteDevToolsContext) based on viteConfig.build.ssr/command, so the SSR Vite instance can never win the race regardless of setup order. Unknown candidates are logged and ignored rather than falling back to first-wins. Register a public `Nuxt` dock group (category "framework", defaultChildId "nuxt:devtools") and point the existing nuxt:devtools hub member at it, both guarded by the same classifier so only the selected client context registers them. Export NUXT_DEVTOOLS_GROUP_ID from @nuxt/devtools-kit as the native extension point for module authors. Narrow @nuxt/devtools' vite peer range to ~8.0.14 to match the Devframe plugins this integration depends on. --- This PR was created with the help of an agent. --- docs/content/1.guide/0.getting-started.md | 6 +- docs/content/2.module/1.utils-kit.md | 22 +++ docs/content/2.module/3.migration-v4.md | 22 ++- packages/devtools-kit/src/index.ts | 23 +++ packages/devtools/package.json | 2 +- packages/devtools/src/module-main.ts | 35 ++++- .../devtools/src/server-rpc/client-context.ts | 31 ++++ packages/devtools/src/server-rpc/index.ts | 26 +++- packages/devtools/test/client-context.test.ts | 45 ++++++ .../test/connect-devtools-kit.test.ts | 139 ++++++++++++++++++ tests/e2e/fixtures/devtools.ts | 2 +- tests/e2e/specs/nuxt-group.spec.ts | 40 +++++ 12 files changed, 376 insertions(+), 17 deletions(-) create mode 100644 packages/devtools/src/server-rpc/client-context.ts create mode 100644 packages/devtools/test/client-context.test.ts create mode 100644 packages/devtools/test/connect-devtools-kit.test.ts create mode 100644 tests/e2e/specs/nuxt-group.spec.ts diff --git a/docs/content/1.guide/0.getting-started.md b/docs/content/1.guide/0.getting-started.md index 08eceff3b4..ca7cca6d3b 100644 --- a/docs/content/1.guide/0.getting-started.md +++ b/docs/content/1.guide/0.getting-started.md @@ -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 diff --git a/docs/content/2.module/1.utils-kit.md b/docs/content/2.module/1.utils-kit.md index 5b429b646f..931ceaf659 100644 --- a/docs/content/2.module/1.utils-kit.md +++ b/docs/content/2.module/1.utils-kit.md @@ -17,6 +17,28 @@ We recommend module authors to install `@nuxt/devtools-kit` as a dependency and ## `@nuxt/devtools-kit` +### `NUXT_DEVTOOLS_GROUP_ID` + +The public `Nuxt` dock group id registered on the Vite DevTools framework +category. `nuxt:devtools` is its default member. Module authors join the +group natively by pointing their own dock entries at it — no special Nuxt API +required: + +```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, + }) +}) +``` + ### `addCustomTab()` ::warning diff --git a/docs/content/2.module/3.migration-v4.md b/docs/content/2.module/3.migration-v4.md index f7952312a2..186f6c97d8 100644 --- a/docs/content/2.module/3.migration-v4.md +++ b/docs/content/2.module/3.migration-v4.md @@ -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 +the exported `NUXT_DEVTOOLS_GROUP_ID`: + +```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, + }) +}) +``` ```diff export default defineNuxtConfig({ diff --git a/packages/devtools-kit/src/index.ts b/packages/devtools-kit/src/index.ts index d7f54c62dc..7818623216 100644 --- a/packages/devtools-kit/src/index.ts +++ b/packages/devtools-kit/src/index.ts @@ -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. * diff --git a/packages/devtools/package.json b/packages/devtools/package.json index bb250f601a..0de6b84125 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -45,7 +45,7 @@ "prepack": "pnpm build" }, "peerDependencies": { - "vite": ">=6.0" + "vite": "~8.0.14" }, "dependencies": { "@nuxt/devtools-kit": "workspace:*", diff --git a/packages/devtools/src/module-main.ts b/packages/devtools/src/module-main.ts index d41b222592..6abf6d7b0a 100644 --- a/packages/devtools/src/module-main.ts +++ b/packages/devtools/src/module-main.ts @@ -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' @@ -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 { classifyViteDevToolsContext } from './server-rpc/client-context' import { readLocalOptions } from './utils/local-options' const MULTIPLE_SLASHES_RE = /\/+/g @@ -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 `classifyViteDevToolsContext`. + if (classifyViteDevToolsContext(ctx) === 'client') { + 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) diff --git a/packages/devtools/src/server-rpc/client-context.ts b/packages/devtools/src/server-rpc/client-context.ts new file mode 100644 index 0000000000..f96699e896 --- /dev/null +++ b/packages/devtools/src/server-rpc/client-context.ts @@ -0,0 +1,31 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' + +export type ViteDevToolsContextClassification = 'client' | 'ssr' | 'unknown' + +/** + * Classify a connecting `ViteDevToolsNodeContext` as the browser-serving + * client Vite instance, the SSR Vite instance, or unknown. + * + * Nuxt's Vite 8 client and SSR resolved configs both expose `client` and + * `ssr` environment names, so `config.environments` cannot tell them apart. + * `config.build.ssr` can: it is only truthy on the SSR build. Check it first. + * + * | Signal | Classification | + * |---|---| + * | `config.build.ssr` is truthy | `'ssr'` | + * | `config.command === 'serve'` and `config.build.ssr` is falsy | `'client'` | + * | missing config or any other combination | `'unknown'` | + */ +export function classifyViteDevToolsContext(ctx: Pick | undefined): ViteDevToolsContextClassification { + const config = ctx?.viteConfig + if (!config) + return 'unknown' + + if (config.build?.ssr) + return 'ssr' + + if (config.command === 'serve') + return 'client' + + return 'unknown' +} diff --git a/packages/devtools/src/server-rpc/index.ts b/packages/devtools/src/server-rpc/index.ts index 178314e9c0..af27db0316 100644 --- a/packages/devtools/src/server-rpc/index.ts +++ b/packages/devtools/src/server-rpc/index.ts @@ -8,6 +8,7 @@ import { colors } from 'consola/utils' import { RPC_NAMESPACE } from '../rpc-namespace' import { setupAnalyzeBuildRPC } from './analyze-build' import { setupAssetsRPC } from './assets' +import { classifyViteDevToolsContext } from './client-context' import { setupCustomTabRPC } from './custom-tabs' import { setupGeneralRPC } from './general' import { createNotifier, setupMessagesRPC } from './messages' @@ -163,15 +164,30 @@ 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 classify each candidate from + * its resolved Vite config (see `classifyViteDevToolsContext`) rather than + * relying on setup order. The SSR candidate is ignored; an unknown candidate + * is logged and also ignored, rather than falling back to first-wins. */ 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) return + + const classification = classifyViteDevToolsContext(kitCtx) + if (classification === 'ssr') + return + + if (classification === 'unknown') { + const config = kitCtx.viteConfig + logger.warn( + colors.yellow('[nuxt-devtools] Could not classify a connecting Vite DevTools context as client or SSR; skipping.\n') + + colors.dim(` command: ${config?.command}, environments: ${config ? Object.keys(config.environments || {}).join(', ') : 'n/a'}, build.ssr: ${config?.build?.ssr}`), + ) + return + } + devtoolsKitCtx = kitCtx const host = kitCtx.rpc diff --git a/packages/devtools/test/client-context.test.ts b/packages/devtools/test/client-context.test.ts new file mode 100644 index 0000000000..46d7c574e6 --- /dev/null +++ b/packages/devtools/test/client-context.test.ts @@ -0,0 +1,45 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import { describe, expect, it } from 'vitest' +import { classifyViteDevToolsContext } from '../src/server-rpc/client-context' + +function fakeCtx(viteConfig: Partial | undefined): Pick { + return { viteConfig } as Pick +} + +describe('classifyViteDevToolsContext', () => { + it('classifies a truthy build.ssr as ssr, regardless of command', () => { + expect(classifyViteDevToolsContext(fakeCtx({ command: 'build', build: { ssr: true } as any }))).toBe('ssr') + expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: { ssr: true } as any }))).toBe('ssr') + }) + + it('classifies command "serve" with a falsy build.ssr as client', () => { + expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: { ssr: false } as any }))).toBe('client') + expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: {} as any }))).toBe('client') + }) + + 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(classifyViteDevToolsContext(bothEnvsSsr)).toBe('ssr') + expect(classifyViteDevToolsContext(bothEnvsClient)).toBe('client') + }) + + it('classifies a missing config as unknown', () => { + expect(classifyViteDevToolsContext(undefined)).toBe('unknown') + expect(classifyViteDevToolsContext(fakeCtx(undefined))).toBe('unknown') + }) + + it('classifies command "build" with a falsy build.ssr as unknown', () => { + expect(classifyViteDevToolsContext(fakeCtx({ command: 'build', build: {} as any }))).toBe('unknown') + }) +}) diff --git a/packages/devtools/test/connect-devtools-kit.test.ts b/packages/devtools/test/connect-devtools-kit.test.ts new file mode 100644 index 0000000000..9cd269261a --- /dev/null +++ b/packages/devtools/test/connect-devtools-kit.test.ts @@ -0,0 +1,139 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import type { Nuxt } from 'nuxt/schema' +import { logger, runWithNuxtContext } from '@nuxt/kit' +import { createHooks } from 'hookable' +import { describe, expect, it, vi } 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'] +const unknownConfig = { command: 'build', build: {} } 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('ignores an unknown candidate without connecting, logging an actionable diagnostic', async () => { + const { connectDevToolsKit, getConnectedKit, readyContexts } = setup() + const unknownCtx = fakeKitCtx(unknownConfig) + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + await connectDevToolsKit(unknownCtx) + + expect(readyContexts).toEqual([]) + expect(getConnectedKit()).toBeUndefined() + expect(warn).toHaveBeenCalledOnce() + const output = warn.mock.calls[0]!.join(' ') + expect(output).toContain('command: build') + expect(output).toContain('build.ssr: undefined') + warn.mockRestore() + }) + + 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) + }) +}) diff --git a/tests/e2e/fixtures/devtools.ts b/tests/e2e/fixtures/devtools.ts index 1163848b87..38b65c3523 100644 --- a/tests/e2e/fixtures/devtools.ts +++ b/tests/e2e/fixtures/devtools.ts @@ -46,7 +46,7 @@ export const test = base.extend({ 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 diff --git a/tests/e2e/specs/nuxt-group.spec.ts b/tests/e2e/specs/nuxt-group.spec.ts new file mode 100644 index 0000000000..eecf8f5adf --- /dev/null +++ b/tests/e2e/specs/nuxt-group.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '../fixtures/devtools' + +// Vite DevTools UI (and its dock registry) is dev-mode only. +test.skip(({ mode }) => mode !== 'dev', 'devtools UI is dev-mode only') + +test('registers a single `Nuxt` group with `nuxt:devtools` as its default child', async ({ page, openDevTools, devtoolsFrame }) => { + await page.goto('/') + await openDevTools() + + const groupEntries = await page.evaluate(() => { + const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__ + return ctx.docks.entries.filter((entry: any) => entry.id === 'nuxt') + }) + + // Exactly one `nuxt` group — Nuxt's SSR Vite context must never register a + // second, inert group + hub member. + expect(groupEntries).toHaveLength(1) + expect(groupEntries[0]).toMatchObject({ + id: 'nuxt', + type: 'group', + title: 'Nuxt', + category: 'framework', + defaultChildId: 'nuxt:devtools', + }) + + const hubEntry = await page.evaluate(() => { + const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__ + return ctx.docks.entries.find((entry: any) => entry.id === 'nuxt:devtools') + }) + expect(hubEntry).toMatchObject({ + id: 'nuxt:devtools', + type: 'iframe', + groupId: 'nuxt', + }) + + // Opening the hub (already driven by `openDevTools()` via `switchEntry`) + // still hydrates the full SideNav. + await expect(devtoolsFrame().locator('#nuxt-devtools-side-nav')) + .toBeVisible({ timeout: 30_000 }) +}) From e4f3f91e9570ab335103c6c04275a79cb0a0346b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 04:01:30 +0000 Subject: [PATCH 2/2] refactor(devtools): simplify client-context classifier into skipInSSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three-way classifyViteDevToolsContext() (client/ssr/unknown, with a logged-and-ignored unknown branch) with a single boolean skipInSSR(ctx), reading only viteConfig.build.ssr. Any non-SSR candidate — including one with an ambiguous or missing config — is now treated as the client rather than logged and ignored. Also apply review feedback from #1031: - peerDependencies.vite: ~8.0.14 -> ^8.0.14 - docs no longer import/reference NUXT_DEVTOOLS_GROUP_ID; examples use the literal groupId: 'nuxt' instead (the constant itself is still exported from @nuxt/devtools-kit for module authors who want it). --- docs/content/2.module/1.utils-kit.md | 14 +++--- docs/content/2.module/3.migration-v4.md | 8 ++-- packages/devtools/package.json | 2 +- packages/devtools/src/module-main.ts | 6 +-- .../devtools/src/server-rpc/client-context.ts | 31 ------------- packages/devtools/src/server-rpc/index.ts | 23 ++-------- .../devtools/src/server-rpc/skip-in-ssr.ts | 14 ++++++ packages/devtools/test/client-context.test.ts | 45 ------------------- .../test/connect-devtools-kit.test.ts | 23 ++++------ packages/devtools/test/skip-in-ssr.test.ts | 41 +++++++++++++++++ 10 files changed, 83 insertions(+), 124 deletions(-) delete mode 100644 packages/devtools/src/server-rpc/client-context.ts create mode 100644 packages/devtools/src/server-rpc/skip-in-ssr.ts delete mode 100644 packages/devtools/test/client-context.test.ts create mode 100644 packages/devtools/test/skip-in-ssr.test.ts diff --git a/docs/content/2.module/1.utils-kit.md b/docs/content/2.module/1.utils-kit.md index 931ceaf659..73d13f1052 100644 --- a/docs/content/2.module/1.utils-kit.md +++ b/docs/content/2.module/1.utils-kit.md @@ -17,15 +17,15 @@ We recommend module authors to install `@nuxt/devtools-kit` as a dependency and ## `@nuxt/devtools-kit` -### `NUXT_DEVTOOLS_GROUP_ID` +### Joining the `Nuxt` Dock Group -The public `Nuxt` dock group id registered on the Vite DevTools framework -category. `nuxt:devtools` is its default member. Module authors join the -group natively by pointing their own dock entries at it — no special Nuxt API -required: +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 { NUXT_DEVTOOLS_GROUP_ID, onDevtoolsReady } from '@nuxt/devtools-kit' +import { onDevtoolsReady } from '@nuxt/devtools-kit' onDevtoolsReady((ctx) => { ctx.docks.register({ @@ -34,7 +34,7 @@ onDevtoolsReady((ctx) => { title: 'My Module', icon: 'i-ph-puzzle-piece', url: '/my-module/', - groupId: NUXT_DEVTOOLS_GROUP_ID, + groupId: 'nuxt', }) }) ``` diff --git a/docs/content/2.module/3.migration-v4.md b/docs/content/2.module/3.migration-v4.md index 186f6c97d8..6b77522498 100644 --- a/docs/content/2.module/3.migration-v4.md +++ b/docs/content/2.module/3.migration-v4.md @@ -219,12 +219,12 @@ The `WizardFunctions`, `WizardActions`, and `GetWizardArgs` types have been remo 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 +`>=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 -the exported `NUXT_DEVTOOLS_GROUP_ID`: +`'nuxt'`: ```ts -import { NUXT_DEVTOOLS_GROUP_ID, onDevtoolsReady } from '@nuxt/devtools-kit' +import { onDevtoolsReady } from '@nuxt/devtools-kit' onDevtoolsReady((ctx) => { ctx.docks.register({ @@ -233,7 +233,7 @@ onDevtoolsReady((ctx) => { title: 'My Module', icon: 'i-ph-puzzle-piece', url: '/my-module/', - groupId: NUXT_DEVTOOLS_GROUP_ID, + groupId: 'nuxt', }) }) ``` diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 0de6b84125..d0c66dd7f5 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -45,7 +45,7 @@ "prepack": "pnpm build" }, "peerDependencies": { - "vite": "~8.0.14" + "vite": "^8.0.14" }, "dependencies": { "@nuxt/devtools-kit": "workspace:*", diff --git a/packages/devtools/src/module-main.ts b/packages/devtools/src/module-main.ts index 6abf6d7b0a..435ced34cc 100644 --- a/packages/devtools/src/module-main.ts +++ b/packages/devtools/src/module-main.ts @@ -16,7 +16,7 @@ import { version } from '../package.json' import { createDefaultTabOptions, setServerTasksEnabledByDefault } from './constant' import { clientDir, packageDir, runtimeDir } from './dirs' import { setupRPC } from './server-rpc' -import { classifyViteDevToolsContext } from './server-rpc/client-context' +import { skipInSSR } from './server-rpc/skip-in-ssr' import { readLocalOptions } from './utils/local-options' const MULTIPLE_SLASHES_RE = /\/+/g @@ -101,8 +101,8 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) { // 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 `classifyViteDevToolsContext`. - if (classifyViteDevToolsContext(ctx) === 'client') { + // group + hub member. See `skipInSSR`. + if (!skipInSSR(ctx)) { ctx.docks.register({ id: NUXT_DEVTOOLS_GROUP_ID, type: 'group', diff --git a/packages/devtools/src/server-rpc/client-context.ts b/packages/devtools/src/server-rpc/client-context.ts deleted file mode 100644 index f96699e896..0000000000 --- a/packages/devtools/src/server-rpc/client-context.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' - -export type ViteDevToolsContextClassification = 'client' | 'ssr' | 'unknown' - -/** - * Classify a connecting `ViteDevToolsNodeContext` as the browser-serving - * client Vite instance, the SSR Vite instance, or unknown. - * - * Nuxt's Vite 8 client and SSR resolved configs both expose `client` and - * `ssr` environment names, so `config.environments` cannot tell them apart. - * `config.build.ssr` can: it is only truthy on the SSR build. Check it first. - * - * | Signal | Classification | - * |---|---| - * | `config.build.ssr` is truthy | `'ssr'` | - * | `config.command === 'serve'` and `config.build.ssr` is falsy | `'client'` | - * | missing config or any other combination | `'unknown'` | - */ -export function classifyViteDevToolsContext(ctx: Pick | undefined): ViteDevToolsContextClassification { - const config = ctx?.viteConfig - if (!config) - return 'unknown' - - if (config.build?.ssr) - return 'ssr' - - if (config.command === 'serve') - return 'client' - - return 'unknown' -} diff --git a/packages/devtools/src/server-rpc/index.ts b/packages/devtools/src/server-rpc/index.ts index af27db0316..d2a1a720cc 100644 --- a/packages/devtools/src/server-rpc/index.ts +++ b/packages/devtools/src/server-rpc/index.ts @@ -8,7 +8,6 @@ import { colors } from 'consola/utils' import { RPC_NAMESPACE } from '../rpc-namespace' import { setupAnalyzeBuildRPC } from './analyze-build' import { setupAssetsRPC } from './assets' -import { classifyViteDevToolsContext } from './client-context' import { setupCustomTabRPC } from './custom-tabs' import { setupGeneralRPC } from './general' import { createNotifier, setupMessagesRPC } from './messages' @@ -17,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' @@ -166,28 +166,13 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) { * 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 classify each candidate from - * its resolved Vite config (see `classifyViteDevToolsContext`) rather than - * relying on setup order. The SSR candidate is ignored; an unknown candidate - * is logged and also ignored, rather than falling back to first-wins. + * client instance has WebSocket peers, so we skip the SSR candidate (see + * `skipInSSR`) rather than relying on setup order. */ async function connectDevToolsKit(kitCtx: ViteDevToolsNodeContext) { - if (devtoolsKitCtx) + if (devtoolsKitCtx || skipInSSR(kitCtx)) return - const classification = classifyViteDevToolsContext(kitCtx) - if (classification === 'ssr') - return - - if (classification === 'unknown') { - const config = kitCtx.viteConfig - logger.warn( - colors.yellow('[nuxt-devtools] Could not classify a connecting Vite DevTools context as client or SSR; skipping.\n') - + colors.dim(` command: ${config?.command}, environments: ${config ? Object.keys(config.environments || {}).join(', ') : 'n/a'}, build.ssr: ${config?.build?.ssr}`), - ) - return - } - devtoolsKitCtx = kitCtx const host = kitCtx.rpc diff --git a/packages/devtools/src/server-rpc/skip-in-ssr.ts b/packages/devtools/src/server-rpc/skip-in-ssr.ts new file mode 100644 index 0000000000..428722a26c --- /dev/null +++ b/packages/devtools/src/server-rpc/skip-in-ssr.ts @@ -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 | undefined): boolean { + return Boolean(ctx?.viteConfig?.build?.ssr) +} diff --git a/packages/devtools/test/client-context.test.ts b/packages/devtools/test/client-context.test.ts deleted file mode 100644 index 46d7c574e6..0000000000 --- a/packages/devtools/test/client-context.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' -import { describe, expect, it } from 'vitest' -import { classifyViteDevToolsContext } from '../src/server-rpc/client-context' - -function fakeCtx(viteConfig: Partial | undefined): Pick { - return { viteConfig } as Pick -} - -describe('classifyViteDevToolsContext', () => { - it('classifies a truthy build.ssr as ssr, regardless of command', () => { - expect(classifyViteDevToolsContext(fakeCtx({ command: 'build', build: { ssr: true } as any }))).toBe('ssr') - expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: { ssr: true } as any }))).toBe('ssr') - }) - - it('classifies command "serve" with a falsy build.ssr as client', () => { - expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: { ssr: false } as any }))).toBe('client') - expect(classifyViteDevToolsContext(fakeCtx({ command: 'serve', build: {} as any }))).toBe('client') - }) - - 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(classifyViteDevToolsContext(bothEnvsSsr)).toBe('ssr') - expect(classifyViteDevToolsContext(bothEnvsClient)).toBe('client') - }) - - it('classifies a missing config as unknown', () => { - expect(classifyViteDevToolsContext(undefined)).toBe('unknown') - expect(classifyViteDevToolsContext(fakeCtx(undefined))).toBe('unknown') - }) - - it('classifies command "build" with a falsy build.ssr as unknown', () => { - expect(classifyViteDevToolsContext(fakeCtx({ command: 'build', build: {} as any }))).toBe('unknown') - }) -}) diff --git a/packages/devtools/test/connect-devtools-kit.test.ts b/packages/devtools/test/connect-devtools-kit.test.ts index 9cd269261a..56debf278f 100644 --- a/packages/devtools/test/connect-devtools-kit.test.ts +++ b/packages/devtools/test/connect-devtools-kit.test.ts @@ -1,8 +1,8 @@ import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' import type { Nuxt } from 'nuxt/schema' -import { logger, runWithNuxtContext } from '@nuxt/kit' +import { runWithNuxtContext } from '@nuxt/kit' import { createHooks } from 'hookable' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { setupRPC } from '../src/server-rpc' /** @@ -49,7 +49,6 @@ function fakeKitCtx(viteConfig: ViteDevToolsNodeContext['viteConfig']): ViteDevT const clientConfig = { command: 'serve', build: { ssr: false } } as ViteDevToolsNodeContext['viteConfig'] const ssrConfig = { command: 'build', build: { ssr: true } } as ViteDevToolsNodeContext['viteConfig'] -const unknownConfig = { command: 'build', build: {} } as ViteDevToolsNodeContext['viteConfig'] function setup() { const nuxt = fakeNuxt() @@ -85,20 +84,16 @@ describe('connectDevToolsKit', () => { expect(getConnectedKit()).toBeUndefined() }) - it('ignores an unknown candidate without connecting, logging an actionable diagnostic', async () => { + 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 unknownCtx = fakeKitCtx(unknownConfig) - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const ambiguousCtx = fakeKitCtx({ command: 'build', build: {} } as ViteDevToolsNodeContext['viteConfig']) - await connectDevToolsKit(unknownCtx) + await connectDevToolsKit(ambiguousCtx) - expect(readyContexts).toEqual([]) - expect(getConnectedKit()).toBeUndefined() - expect(warn).toHaveBeenCalledOnce() - const output = warn.mock.calls[0]!.join(' ') - expect(output).toContain('command: build') - expect(output).toContain('build.ssr: undefined') - warn.mockRestore() + expect(readyContexts).toEqual([ambiguousCtx]) + expect(getConnectedKit()).toBe(ambiguousCtx) }) it('connects the client candidate when the SSR candidate arrives first', async () => { diff --git a/packages/devtools/test/skip-in-ssr.test.ts b/packages/devtools/test/skip-in-ssr.test.ts new file mode 100644 index 0000000000..15eaae9b75 --- /dev/null +++ b/packages/devtools/test/skip-in-ssr.test.ts @@ -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 | undefined): Pick { + return { viteConfig } as Pick +} + +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) + }) +})