Skip to content

Commit ff0512e

Browse files
antfubotantfu
andauthored
feat: add @nuxt/devtools-onboard as the opt-in entry point of Nuxt DevTools (#1117)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 6521d26 commit ff0512e

20 files changed

Lines changed: 408 additions & 31 deletions

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@antfu/eslint-config": "catalog:cli",
3535
"@antfu/ni": "catalog:cli",
3636
"@nuxt/devtools": "workspace:*",
37+
"@nuxt/devtools-onboard": "workspace:*",
3738
"@nuxt/devtools-ui-kit": "workspace:*",
3839
"@nuxt/eslint": "catalog:cli",
3940
"@nuxt/module-builder": "catalog:buildtools",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# @nuxt/devtools-onboard
2+
3+
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:
4+
5+
- when `@nuxt/devtools` is installed in your project, it loads it;
6+
- 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.
7+
8+
Nothing is injected outside `nuxt dev`.
9+
10+
## Turning it off
11+
12+
- `devtools: false` (or `devtools: { enabled: false }`) in `nuxt.config` disables both the button and DevTools.
13+
- **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.
14+
- **Hide for now** hides the button for the current tab only.
15+
16+
## Nuxt 4
17+
18+
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.
19+
20+
```ts
21+
export default defineNuxtConfig({
22+
modules: ['@nuxt/devtools-onboard'],
23+
})
24+
```
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@nuxt/devtools-onboard",
3+
"type": "module",
4+
"version": "4.0.0-beta.3",
5+
"description": "The entry point of Nuxt DevTools: loads it when installed, otherwise offers to install it from a floating button.",
6+
"license": "MIT",
7+
"homepage": "https://devtools.nuxt.com",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/nuxt/devtools.git",
11+
"directory": "packages/devtools-onboard"
12+
},
13+
"exports": {
14+
".": "./dist/module.mjs"
15+
},
16+
"types": "./dist/module.d.mts",
17+
"files": [
18+
"dist"
19+
],
20+
"scripts": {
21+
"build": "nuxt-build-module build",
22+
"stub": "nuxt-build-module build --stub",
23+
"dev:prepare": "pnpm run stub",
24+
"prepack": "turbo run build --filter=."
25+
},
26+
"peerDependencies": {
27+
"@nuxt/devtools": "workspace:^",
28+
"@nuxt/kit": "^4.0.0-0 || ^5.0.0-0"
29+
},
30+
"peerDependenciesMeta": {
31+
"@nuxt/devtools": {
32+
"optional": true
33+
}
34+
},
35+
"dependencies": {
36+
"@devframes/hub-ui-onboard": "catalog:prod",
37+
"pkg-types": "catalog:prod"
38+
},
39+
"devDependencies": {
40+
"@nuxt/kit": "catalog:prod",
41+
"@nuxt/schema": "catalog:types",
42+
"nuxt": "catalog:buildtools"
43+
}
44+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { NuxtOptions } from '@nuxt/schema'
2+
import { fileURLToPath } from 'node:url'
3+
import { createOnboarding } from '@devframes/hub-ui-onboard'
4+
import { defineNuxtModule, installModule, tryResolveModule } from '@nuxt/kit'
5+
import { readPackageJSON } from 'pkg-types'
6+
7+
const DEVTOOLS = '@nuxt/devtools'
8+
9+
/** The names Nuxt itself recognizes as DevTools in `modules` (see `loadNuxt` in nuxt core). */
10+
const DEVTOOLS_MODULES = new Set([DEVTOOLS, `${DEVTOOLS}-nightly`, `${DEVTOOLS}-edge`])
11+
12+
function listsDevtools(modules: NuxtOptions['modules']): boolean {
13+
return modules.some((entry) => {
14+
const id = Array.isArray(entry) ? entry[0] : entry
15+
return typeof id === 'string' && DEVTOOLS_MODULES.has(id)
16+
})
17+
}
18+
19+
/**
20+
* The spec the button installs. Versions are released in lockstep, so a caret
21+
* on our own version pins the matching DevTools; a nightly build carries an
22+
* `npm:@nuxt/devtools-nightly@…` alias in its peer range instead.
23+
*/
24+
async function devtoolsSpec(): Promise<string> {
25+
const { version, peerDependencies } = await readPackageJSON(fileURLToPath(import.meta.url))
26+
const range = peerDependencies?.[DEVTOOLS] ?? ''
27+
return `${DEVTOOLS}@${range.startsWith('npm:') ? range : `^${version}`}`
28+
}
29+
30+
export default defineNuxtModule({
31+
meta: {
32+
name: '@nuxt/devtools-onboard',
33+
},
34+
async setup(_, nuxt) {
35+
const { devtools } = nuxt.options
36+
if (devtools === false || (typeof devtools === 'object' && devtools.enabled === false))
37+
return
38+
if (listsDevtools(nuxt.options.modules))
39+
return
40+
41+
const installed = await tryResolveModule(DEVTOOLS, nuxt.options.modulesDir)
42+
if (installed)
43+
return installModule(installed)
44+
if (!nuxt.options.dev)
45+
return
46+
47+
const onboarding = createOnboarding({
48+
cwd: nuxt.options.rootDir,
49+
// Where Vite DevTools mounts the hub, so after the restart the real
50+
// `embedded.js` answers at the URL the button was loaded from.
51+
base: '/__devtools/',
52+
packages: [await devtoolsSpec()],
53+
branding: {
54+
productName: 'Nuxt DevTools',
55+
primaryColor: '#099e61',
56+
logo: 'https://nuxt.com/assets/design-kit/icon-green.svg',
57+
},
58+
messages: {
59+
restart: 'Installed. Restarting the dev server…',
60+
},
61+
onInstalled() {
62+
// A Nuxt module only mounts during setup, so the dev server has to
63+
// start over. Delay past the install response so the panel can show
64+
// the restart message before the process goes away.
65+
setTimeout(() => nuxt.callHook('restart', { hard: true }), 100)
66+
},
67+
})
68+
69+
nuxt.hook('vite:serverCreated', (server, { isClient }) => {
70+
if (isClient)
71+
server.middlewares.use(onboarding.nodeMiddleware)
72+
})
73+
if (!onboarding.disabled)
74+
(nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc, tagPosition: 'bodyClose' })
75+
},
76+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Nuxt, NuxtConfig } from '@nuxt/schema'
2+
import type { IncomingMessage, ServerResponse } from 'node:http'
3+
import { mkdtemp, rm } from 'node:fs/promises'
4+
import { createServer } from 'node:http'
5+
import { tmpdir } from 'node:os'
6+
import { join } from 'node:path'
7+
import { fileURLToPath } from 'node:url'
8+
import { loadNuxt } from 'nuxt'
9+
import { afterEach, describe, expect, it } from 'vitest'
10+
11+
const onboardModule = fileURLToPath(new URL('../src/module.ts', import.meta.url))
12+
13+
const cleanups: (() => Promise<void>)[] = []
14+
afterEach(async () => {
15+
await Promise.all(cleanups.splice(0).map(fn => fn()))
16+
})
17+
18+
/**
19+
* Nuxt core pushes the bare `@nuxt/devtools` name into `_modules`; aliasing
20+
* it to this module reproduces what Nuxt 5 does with `@nuxt/devtools-onboard`.
21+
* `withoutDevtools` drops every `node_modules` Nuxt can resolve from, which is
22+
* the situation of a Nuxt that no longer depends on `@nuxt/devtools` itself.
23+
*/
24+
async function bootNuxt(overrides: NuxtConfig, { withoutDevtools = false } = {}): Promise<Nuxt> {
25+
const rootDir = await mkdtemp(join(tmpdir(), 'devtools-onboard-'))
26+
const nuxt = await loadNuxt({
27+
cwd: rootDir,
28+
ready: false,
29+
overrides: {
30+
alias: { '@nuxt/devtools': onboardModule },
31+
telemetry: false,
32+
...overrides,
33+
},
34+
})
35+
cleanups.push(async () => {
36+
await nuxt.close()
37+
await rm(rootDir, { recursive: true, force: true })
38+
})
39+
if (withoutDevtools)
40+
nuxt.options.modulesDir = [join(rootDir, 'node_modules')]
41+
await nuxt.ready()
42+
return nuxt
43+
}
44+
45+
function onboardScripts(nuxt: Nuxt) {
46+
return (nuxt.options.app.head.script ?? []).filter(script => typeof script === 'object' && script.src === '/__devtools/embedded.js')
47+
}
48+
49+
type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void
50+
51+
async function viteMiddlewareFetch(nuxt: Nuxt, path: string): Promise<Response> {
52+
const mounted: Middleware[] = []
53+
// Only `middlewares.use` is read; the rest of a real ViteDevServer is irrelevant here.
54+
const viteServer = { middlewares: { use: (fn: Middleware) => mounted.push(fn) } } as never
55+
await nuxt.callHook('vite:serverCreated', viteServer, { isClient: true, isServer: false })
56+
const [middleware] = mounted
57+
if (!middleware)
58+
throw new Error('onboarding middleware was not mounted')
59+
60+
const server = createServer((req, res) => middleware(req, res, () => {
61+
res.statusCode = 404
62+
res.end()
63+
}))
64+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
65+
cleanups.push(() => new Promise(resolve => server.close(() => resolve())))
66+
const { port } = server.address() as { port: number } // listening on a TCP port, never a pipe
67+
return fetch(`http://127.0.0.1:${port}${path}`)
68+
}
69+
70+
describe('@nuxt/devtools-onboard', () => {
71+
it('does nothing when devtools are disabled', async () => {
72+
const nuxt = await bootNuxt({ devtools: false, modules: [onboardModule] })
73+
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined()
74+
expect(onboardScripts(nuxt)).toHaveLength(0)
75+
}, 60_000)
76+
77+
it('loads @nuxt/devtools when it is installed', async () => {
78+
const nuxt = await bootNuxt({})
79+
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBe(true)
80+
expect(onboardScripts(nuxt)).toHaveLength(0)
81+
}, 60_000)
82+
83+
it('offers to install @nuxt/devtools when it is missing', async () => {
84+
const nuxt = await bootNuxt({ dev: true }, { withoutDevtools: true })
85+
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined()
86+
expect(onboardScripts(nuxt)).toHaveLength(1)
87+
88+
const status = await viteMiddlewareFetch(nuxt, '/__devtools/__onboard/status').then(r => r.json())
89+
expect(status.state).toBe('idle')
90+
// No lockfile in the fixture, so the package manager falls back to npm.
91+
expect(status.command).toBe('npm i -D @nuxt/devtools@^4.0.0-beta.3')
92+
expect(status.branding.productName).toBe('Nuxt DevTools')
93+
}, 60_000)
94+
95+
it('stays out of the way outside nuxt dev when @nuxt/devtools is missing', async () => {
96+
const nuxt = await bootNuxt({ dev: false }, { withoutDevtools: true })
97+
expect(onboardScripts(nuxt)).toHaveLength(0)
98+
}, 60_000)
99+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"compilerOptions": {
3+
"target": "esnext",
4+
"module": "esnext",
5+
"moduleResolution": "Bundler",
6+
"strict": true
7+
}
8+
}

‎playgrounds-ecosystem/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ One minimal app per Nuxt major, because Nuxt 4 ships Nitro v2 and Nuxt 5 ships
2020
Nitro v3, and `@nuxt/devtools` / `@nuxt/devtools-kit` declare both as *optional*
2121
peers. Each is a sealed pnpm workspace with its own lockfile, and installs
2222
DevTools from **packed tarballs** — the real npm install path, from `dist`.
23+
Both list `@nuxt/devtools-onboard` in `modules`, the entry point Nuxt 5 ships,
24+
so they also cover its hand-off to the installed `@nuxt/devtools`.
2325

2426
```sh
2527
# Build the monorepo, pack DevTools into .tarballs/, install

‎playgrounds-ecosystem/nuxt4/nuxt.config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
//
1212
// Nuxt 4 ships Nitro v2 (the `nitropack` package). See ../README.md.
1313
export default defineNuxtConfig({
14+
// The entry point Nuxt 5 ships: it loads the installed `@nuxt/devtools`.
1415
modules: [
15-
'@nuxt/devtools',
16+
'@nuxt/devtools-onboard',
1617
],
1718

1819
compatibilityDate: '2024-09-19',

‎playgrounds-ecosystem/nuxt4/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
},
1212
"dependencies": {
1313
"@nuxt/devtools": "file:.tarballs/nuxt-devtools.tgz",
14-
"@nuxt/devtools-assets": "file:.tarballs/nuxt-devtools-assets.tgz"
14+
"@nuxt/devtools-assets": "file:.tarballs/nuxt-devtools-assets.tgz",
15+
"@nuxt/devtools-onboard": "file:.tarballs/nuxt-devtools-onboard.tgz"
1516
},
1617
"devDependencies": {
1718
"@types/node": "^26.1.1",

‎playgrounds-ecosystem/nuxt4/pnpm-lock.yaml‎

Lines changed: 36 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)