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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@antfu/eslint-config": "catalog:cli",
"@antfu/ni": "catalog:cli",
"@nuxt/devtools": "workspace:*",
"@nuxt/devtools-onboard": "workspace:*",
"@nuxt/devtools-ui-kit": "workspace:*",
"@nuxt/eslint": "catalog:cli",
"@nuxt/module-builder": "catalog:buildtools",
Expand Down
24 changes: 24 additions & 0 deletions packages/devtools-onboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# @nuxt/devtools-onboard

The entry point of [Nuxt DevTools](https://devtools.nuxt.com). It is a tiny Nuxt module that Nuxt 5 ships instead of depending on `@nuxt/devtools` directly:

- when `@nuxt/devtools` is installed in your project, it loads it;
- when it is not, `nuxt dev` shows a floating button (built on [`@devframes/hub-ui-onboard`](https://devfra.me/guide/hub-ui-onboard)) that installs it with your package manager and restarts the dev server, so the real DevTools dock takes its place.

Nothing is injected outside `nuxt dev`.

## Turning it off

- `devtools: false` (or `devtools: { enabled: false }`) in `nuxt.config` disables both the button and DevTools.
- **Disable entirely** in the button's panel writes `node_modules/.devframe/hub-ui-onboard.json`; the button stays hidden on later starts until you delete that file or set the `devtools` option explicitly.
- **Hide for now** hides the button for the current tab only.

## Nuxt 4

Nuxt 4 depends on `@nuxt/devtools` itself, so this package is not needed there. Adding it to `modules` still works: it loads the installed DevTools and never shows the button.

```ts
export default defineNuxtConfig({
modules: ['@nuxt/devtools-onboard'],
})
```
44 changes: 44 additions & 0 deletions packages/devtools-onboard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@nuxt/devtools-onboard",
"type": "module",
"version": "4.0.0-beta.3",
"description": "The entry point of Nuxt DevTools: loads it when installed, otherwise offers to install it from a floating button.",
"license": "MIT",
"homepage": "https://devtools.nuxt.com",
"repository": {
"type": "git",
"url": "git+https://github.com/nuxt/devtools.git",
"directory": "packages/devtools-onboard"
},
"exports": {
".": "./dist/module.mjs"
},
"types": "./dist/module.d.mts",
"files": [
"dist"
],
"scripts": {
"build": "nuxt-build-module build",
"stub": "nuxt-build-module build --stub",
"dev:prepare": "pnpm run stub",
"prepack": "turbo run build --filter=."
},
"peerDependencies": {
"@nuxt/devtools": "workspace:^",
"@nuxt/kit": "^4.0.0-0 || ^5.0.0-0"
},
"peerDependenciesMeta": {
"@nuxt/devtools": {
"optional": true
}
},
"dependencies": {
"@devframes/hub-ui-onboard": "catalog:prod",
"pkg-types": "catalog:prod"
},
"devDependencies": {
"@nuxt/kit": "catalog:prod",
"@nuxt/schema": "catalog:types",
"nuxt": "catalog:buildtools"
}
}
76 changes: 76 additions & 0 deletions packages/devtools-onboard/src/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { NuxtOptions } from '@nuxt/schema'
import { fileURLToPath } from 'node:url'
import { createOnboarding } from '@devframes/hub-ui-onboard'
import { defineNuxtModule, installModule, tryResolveModule } from '@nuxt/kit'
import { readPackageJSON } from 'pkg-types'

const DEVTOOLS = '@nuxt/devtools'

/** The names Nuxt itself recognizes as DevTools in `modules` (see `loadNuxt` in nuxt core). */
const DEVTOOLS_MODULES = new Set([DEVTOOLS, `${DEVTOOLS}-nightly`, `${DEVTOOLS}-edge`])

function listsDevtools(modules: NuxtOptions['modules']): boolean {
return modules.some((entry) => {
const id = Array.isArray(entry) ? entry[0] : entry
return typeof id === 'string' && DEVTOOLS_MODULES.has(id)
})
}

/**
* The spec the button installs. Versions are released in lockstep, so a caret
* on our own version pins the matching DevTools; a nightly build carries an
* `npm:@nuxt/devtools-nightly@…` alias in its peer range instead.
*/
async function devtoolsSpec(): Promise<string> {
const { version, peerDependencies } = await readPackageJSON(fileURLToPath(import.meta.url))
const range = peerDependencies?.[DEVTOOLS] ?? ''
return `${DEVTOOLS}@${range.startsWith('npm:') ? range : `^${version}`}`
}

export default defineNuxtModule({
meta: {
name: '@nuxt/devtools-onboard',
},
async setup(_, nuxt) {
const { devtools } = nuxt.options
if (devtools === false || (typeof devtools === 'object' && devtools.enabled === false))
return
if (listsDevtools(nuxt.options.modules))
return

const installed = await tryResolveModule(DEVTOOLS, nuxt.options.modulesDir)
if (installed)
return installModule(installed)
if (!nuxt.options.dev)
return

const onboarding = createOnboarding({
cwd: nuxt.options.rootDir,
// Where Vite DevTools mounts the hub, so after the restart the real
// `embedded.js` answers at the URL the button was loaded from.
base: '/__devtools/',
packages: [await devtoolsSpec()],
branding: {
productName: 'Nuxt DevTools',
primaryColor: '#099e61',
logo: 'https://nuxt.com/assets/design-kit/icon-green.svg',
},
messages: {
restart: 'Installed. Restarting the dev server…',
},
onInstalled() {
// A Nuxt module only mounts during setup, so the dev server has to
// start over. Delay past the install response so the panel can show
// the restart message before the process goes away.
setTimeout(() => nuxt.callHook('restart', { hard: true }), 100)
},
})

nuxt.hook('vite:serverCreated', (server, { isClient }) => {
if (isClient)
server.middlewares.use(onboarding.nodeMiddleware)
})
if (!onboarding.disabled)
(nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc, tagPosition: 'bodyClose' })
},
})
99 changes: 99 additions & 0 deletions packages/devtools-onboard/test/module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Nuxt, NuxtConfig } from '@nuxt/schema'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadNuxt } from 'nuxt'
import { afterEach, describe, expect, it } from 'vitest'

const onboardModule = fileURLToPath(new URL('../src/module.ts', import.meta.url))

const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
await Promise.all(cleanups.splice(0).map(fn => fn()))
})

/**
* Nuxt core pushes the bare `@nuxt/devtools` name into `_modules`; aliasing
* it to this module reproduces what Nuxt 5 does with `@nuxt/devtools-onboard`.
* `withoutDevtools` drops every `node_modules` Nuxt can resolve from, which is
* the situation of a Nuxt that no longer depends on `@nuxt/devtools` itself.
*/
async function bootNuxt(overrides: NuxtConfig, { withoutDevtools = false } = {}): Promise<Nuxt> {
const rootDir = await mkdtemp(join(tmpdir(), 'devtools-onboard-'))
const nuxt = await loadNuxt({
cwd: rootDir,
ready: false,
overrides: {
alias: { '@nuxt/devtools': onboardModule },
telemetry: false,
...overrides,
},
})
cleanups.push(async () => {
await nuxt.close()
await rm(rootDir, { recursive: true, force: true })
})
if (withoutDevtools)
nuxt.options.modulesDir = [join(rootDir, 'node_modules')]
await nuxt.ready()
return nuxt
}

function onboardScripts(nuxt: Nuxt) {
return (nuxt.options.app.head.script ?? []).filter(script => typeof script === 'object' && script.src === '/__devtools/embedded.js')
}

type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void

async function viteMiddlewareFetch(nuxt: Nuxt, path: string): Promise<Response> {
const mounted: Middleware[] = []
// Only `middlewares.use` is read; the rest of a real ViteDevServer is irrelevant here.
const viteServer = { middlewares: { use: (fn: Middleware) => mounted.push(fn) } } as never
await nuxt.callHook('vite:serverCreated', viteServer, { isClient: true, isServer: false })
const [middleware] = mounted
if (!middleware)
throw new Error('onboarding middleware was not mounted')

const server = createServer((req, res) => middleware(req, res, () => {
res.statusCode = 404
res.end()
}))
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
cleanups.push(() => new Promise(resolve => server.close(() => resolve())))
const { port } = server.address() as { port: number } // listening on a TCP port, never a pipe
return fetch(`http://127.0.0.1:${port}${path}`)
}

describe('@nuxt/devtools-onboard', () => {
it('does nothing when devtools are disabled', async () => {
const nuxt = await bootNuxt({ devtools: false, modules: [onboardModule] })
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined()
expect(onboardScripts(nuxt)).toHaveLength(0)
}, 60_000)

it('loads @nuxt/devtools when it is installed', async () => {
const nuxt = await bootNuxt({})
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBe(true)
expect(onboardScripts(nuxt)).toHaveLength(0)
}, 60_000)

it('offers to install @nuxt/devtools when it is missing', async () => {
const nuxt = await bootNuxt({ dev: true }, { withoutDevtools: true })
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined()
expect(onboardScripts(nuxt)).toHaveLength(1)

const status = await viteMiddlewareFetch(nuxt, '/__devtools/__onboard/status').then(r => r.json())
expect(status.state).toBe('idle')
// No lockfile in the fixture, so the package manager falls back to npm.
expect(status.command).toBe('npm i -D @nuxt/devtools@^4.0.0-beta.3')
expect(status.branding.productName).toBe('Nuxt DevTools')
}, 60_000)

it('stays out of the way outside nuxt dev when @nuxt/devtools is missing', async () => {
const nuxt = await bootNuxt({ dev: false }, { withoutDevtools: true })
expect(onboardScripts(nuxt)).toHaveLength(0)
}, 60_000)
})
8 changes: 8 additions & 0 deletions packages/devtools-onboard/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "Bundler",
"strict": true
}
}
2 changes: 2 additions & 0 deletions playgrounds-ecosystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ One minimal app per Nuxt major, because Nuxt 4 ships Nitro v2 and Nuxt 5 ships
Nitro v3, and `@nuxt/devtools` / `@nuxt/devtools-kit` declare both as *optional*
peers. Each is a sealed pnpm workspace with its own lockfile, and installs
DevTools from **packed tarballs** — the real npm install path, from `dist`.
Both list `@nuxt/devtools-onboard` in `modules`, the entry point Nuxt 5 ships,
so they also cover its hand-off to the installed `@nuxt/devtools`.

```sh
# Build the monorepo, pack DevTools into .tarballs/, install
Expand Down
3 changes: 2 additions & 1 deletion playgrounds-ecosystem/nuxt4/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
//
// Nuxt 4 ships Nitro v2 (the `nitropack` package). See ../README.md.
export default defineNuxtConfig({
// The entry point Nuxt 5 ships: it loads the installed `@nuxt/devtools`.
modules: [
'@nuxt/devtools',
'@nuxt/devtools-onboard',
],

compatibilityDate: '2024-09-19',
Expand Down
3 changes: 2 additions & 1 deletion playgrounds-ecosystem/nuxt4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
"dependencies": {
"@nuxt/devtools": "file:.tarballs/nuxt-devtools.tgz",
"@nuxt/devtools-assets": "file:.tarballs/nuxt-devtools-assets.tgz"
"@nuxt/devtools-assets": "file:.tarballs/nuxt-devtools-assets.tgz",
"@nuxt/devtools-onboard": "file:.tarballs/nuxt-devtools-onboard.tgz"
},
"devDependencies": {
"@types/node": "^26.1.1",
Expand Down
41 changes: 36 additions & 5 deletions playgrounds-ecosystem/nuxt4/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading