|
| 1 | +--- |
| 2 | +title: 'Opt-in DevTools with Onboarding' |
| 3 | +navigation: |
| 4 | + icon: i-lucide-download |
| 5 | +description: '@devframes/hub-ui-onboard lets a host ship a 20 kB floating button instead of the hub, install the hub on demand, and hand the hub base to it without a restart.' |
| 6 | +--- |
| 7 | + |
| 8 | +`@devframes/hub-ui-onboard` lets a host ship a 20 kB floating button instead of the hub, install the hub on demand, and hand the hub base to it in the same process. |
| 9 | + |
| 10 | +## Why |
| 11 | + |
| 12 | +A hub UI provider, its Vue runtime, and the devframes it mounts add tens of megabytes to a framework's install size. A host that wants DevTools as an opt-in can move those packages to optional peers and ship only this package: one browser file, a handful of routes, and three small runtime dependencies (a package-manager detector, a process runner, the diagnostics library). The user still discovers DevTools through the usual floating button; the first click installs them. |
| 13 | + |
| 14 | +## What the user sees |
| 15 | + |
| 16 | +The button sits at the bottom left, dimmed until hovered. It opens a panel with the product name and logo, one sentence, the exact command the install will run (for example `pnpm add -D @nuxt/devtools`), and three actions: |
| 17 | + |
| 18 | +- **Install** runs the command in the project. The panel shows progress, then either the real dock replaces the button in place, or the panel asks for a restart. |
| 19 | +- **Hide for now** removes the button for the current browser tab. |
| 20 | +- **Disable entirely** writes a state file so the host stops injecting the button on every later start. |
| 21 | + |
| 22 | +The panel follows the shared design tokens, the host's `primaryColor`, and the user's hub color scheme, so the swap to the real dock looks like one product. |
| 23 | + |
| 24 | +## Create the onboarding |
| 25 | + |
| 26 | +```ts |
| 27 | +import { createOnboarding } from '@devframes/hub-ui-onboard' |
| 28 | + |
| 29 | +const onboarding = createOnboarding({ |
| 30 | + packages: ['@devframes/hub', '@devframes/hub-ui'], |
| 31 | + branding: { productName: 'My DevTools', logo: '/logo.svg', primaryColor: '#646cff' }, |
| 32 | +}) |
| 33 | +``` |
| 34 | + |
| 35 | +`createOnboarding()` returns four things: |
| 36 | + |
| 37 | +- `handler(request)`: a web-standard `Request => Response` handler for every path under `base` (default `/__devframes/`). |
| 38 | +- `nodeMiddleware(req, res, next)`: the same handler as Connect middleware for Vite, Express, Fastify with `@fastify/middie`, or a plain `node:http` server. It calls `next()` for paths outside `base`. |
| 39 | +- `scriptSrc`: `<base>embedded.js`, the URL to inject as `<script type="module">`. |
| 40 | +- `disabled` and `installed`: what the host needs to decide whether to inject the script at all (below). |
| 41 | + |
| 42 | +`packages` are package specs as the package manager accepts them. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn`, `bun`, `deno`), `dev: true` adds `-D`, and `cwd` (default `process.cwd()`) is the project that receives the dependency. In a workspace, point `cwd` at the package that runs the dev server. The packages are fixed at creation, and a `POST` from another origin is refused. |
| 43 | + |
| 44 | +## Hand the base to the hub |
| 45 | + |
| 46 | +Return a handler from `onInstalled` and the hub takes over `base` in the same process. The button then loads the real `embedded.js` from that handler and removes itself. |
| 47 | + |
| 48 | +```ts |
| 49 | +import { createRequire } from 'node:module' |
| 50 | +import { join } from 'node:path' |
| 51 | +import { pathToFileURL } from 'node:url' |
| 52 | + |
| 53 | +const require = createRequire(join(cwd, 'package.json')) |
| 54 | +const load = <T>(id: string): Promise<T> => import(pathToFileURL(require.resolve(id)).href) |
| 55 | + |
| 56 | +const onboarding = createOnboarding({ |
| 57 | + cwd, |
| 58 | + packages: ['@devframes/hub', '@devframes/hub-ui'], |
| 59 | + async onInstalled() { |
| 60 | + const [{ initHub }, { createUi }] = await Promise.all([ |
| 61 | + load<typeof import('@devframes/hub/initiate')>('@devframes/hub/initiate'), |
| 62 | + load<typeof import('@devframes/hub-ui')>('@devframes/hub-ui'), |
| 63 | + ]) |
| 64 | + const hub = initHub({ base: '/__devframes/', cwd, server: httpServer, ui: createUi(), devframes: [] }) |
| 65 | + return hub.handler |
| 66 | + }, |
| 67 | +}) |
| 68 | +``` |
| 69 | + |
| 70 | +Resolve the new packages from the project's `package.json`, as above. Under pnpm they are dependencies of the project, so a bare `import('@devframes/hub')` from the host's own file fails. Pass the live `node:http` server so the hub attaches its WebSocket to it; `initHub` accepts a server after it started listening. |
| 71 | + |
| 72 | +`onInstalled` is also how the onboarding short-circuits. When every named package is already in `node_modules` at creation, `onboarding.installed` is `true`, `onInstalled` runs on the first request, and the user never sees the button. A host can therefore mount the onboarding unconditionally during development: it serves the hub when the packages exist and the button when they are missing. |
| 73 | + |
| 74 | +Without `onInstalled`, or when it returns nothing, the panel reports the install and asks for a restart. The next start finds the packages installed. |
| 75 | + |
| 76 | +## When to inject the button |
| 77 | + |
| 78 | +Inject `scriptSrc` only when `onboarding.disabled` is `false`. The user set that flag with "Disable entirely"; it lives in `<stateDir>/hub-ui-onboard.json`, default `<cwd>/node_modules/.devframe`. |
| 79 | + |
| 80 | +Mount the onboarding only when the user did not set the host's own devtools option. An explicit `devtools: true` means the host installs or requires DevTools itself; an explicit `devtools: false` means no button. The onboarding covers the unset case, and a user who disabled it from the panel turns it back on by setting the option. |
| 81 | + |
| 82 | +## Hosts |
| 83 | + |
| 84 | +### Vite |
| 85 | + |
| 86 | +A plugin mounts the middleware and injects the tag. `examples/hub-onboard-vite` is the complete version with the hub hand-off. |
| 87 | + |
| 88 | +```ts [vite.config.ts] |
| 89 | +import type { Plugin } from 'vite' |
| 90 | +import { createOnboarding } from '@devframes/hub-ui-onboard' |
| 91 | + |
| 92 | +function hubOnboarding(): Plugin { |
| 93 | + const onboarding = createOnboarding({ packages: ['@devframes/hub', '@devframes/hub-ui'] }) |
| 94 | + return { |
| 95 | + name: 'hub-onboarding', |
| 96 | + apply: 'serve', |
| 97 | + configureServer(server) { |
| 98 | + server.middlewares.use(onboarding.nodeMiddleware) |
| 99 | + }, |
| 100 | + transformIndexHtml() { |
| 101 | + return onboarding.disabled |
| 102 | + ? [] |
| 103 | + : [{ tag: 'script', attrs: { type: 'module', src: onboarding.scriptSrc }, injectTo: 'body' }] |
| 104 | + }, |
| 105 | + } |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +### Nuxt |
| 110 | + |
| 111 | +Nuxt runs Vite, so a module reuses the plugin above and adds the tag to `app.head`. Gate it on the dev server, and skip it when the user set your own devtools option. |
| 112 | + |
| 113 | +```ts [modules/devtools-onboarding.ts] |
| 114 | +import { createOnboarding } from '@devframes/hub-ui-onboard' |
| 115 | +import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' |
| 116 | + |
| 117 | +export default defineNuxtModule({ |
| 118 | + setup(_, nuxt) { |
| 119 | + if (!nuxt.options.dev) |
| 120 | + return |
| 121 | + const onboarding = createOnboarding({ |
| 122 | + cwd: nuxt.options.rootDir, |
| 123 | + packages: ['@nuxt/devtools'], |
| 124 | + branding: { productName: 'Nuxt DevTools', primaryColor: '#00dc82' }, |
| 125 | + }) |
| 126 | + addVitePlugin({ |
| 127 | + name: 'devtools-onboarding', |
| 128 | + configureServer: server => server.middlewares.use(onboarding.nodeMiddleware), |
| 129 | + }) |
| 130 | + if (!onboarding.disabled) |
| 131 | + (nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc }) |
| 132 | + }, |
| 133 | +}) |
| 134 | +``` |
| 135 | + |
| 136 | +### Next.js |
| 137 | + |
| 138 | +A route handler forwards `Request` objects, and the root layout renders the tag. |
| 139 | + |
| 140 | +```ts [app/__devframes/[[...path]]/route.ts] |
| 141 | +import { onboarding } from '../../../devtools-onboarding' |
| 142 | + |
| 143 | +export const GET = (request: Request) => onboarding.handler(request) |
| 144 | +export const POST = (request: Request) => onboarding.handler(request) |
| 145 | +``` |
| 146 | + |
| 147 | +```tsx [app/layout.tsx] |
| 148 | +import { onboarding } from '../devtools-onboarding' |
| 149 | + |
| 150 | +export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 151 | + return ( |
| 152 | + <html lang="en"> |
| 153 | + <body> |
| 154 | + {children} |
| 155 | + {process.env.NODE_ENV === 'development' && !onboarding.disabled && ( |
| 156 | + <script type="module" src={onboarding.scriptSrc} /> |
| 157 | + )} |
| 158 | + </body> |
| 159 | + </html> |
| 160 | + ) |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +A Next route handler cannot accept a WebSocket upgrade, so a hub started from `onInstalled` uses a side-car socket (`ws: { sidecar: true }`); see [Next](/frameworks/next#mounting-a-hub). |
| 165 | + |
| 166 | +### Any Node server |
| 167 | + |
| 168 | +```ts |
| 169 | +import { createServer } from 'node:http' |
| 170 | + |
| 171 | +createServer((req, res) => { |
| 172 | + onboarding.nodeMiddleware(req, res, () => { |
| 173 | + res.statusCode = 404 |
| 174 | + res.end() |
| 175 | + }) |
| 176 | +}).listen(3000) |
| 177 | +``` |
| 178 | + |
| 179 | +Frameworks with a `Request => Response` surface (Hono, Nitro, Deno) mount `onboarding.handler` under `base` instead. |
| 180 | + |
| 181 | +## Strings and branding |
| 182 | + |
| 183 | +`branding` takes `productName`, `logo` (one URL or `{ light, dark }`) and `primaryColor`, the same three fields hub-ui's `DevframeBranding` starts with. Every string in the panel derives from `productName` and is overridable through `messages`; the keys are listed in the [Hub API reference](/references/hub-api#onboarding-options-and-routes). |
| 184 | + |
| 185 | +## Errors |
| 186 | + |
| 187 | +The Node side reports through `DF9000` to `DF9004`. An install failure or a throwing `onInstalled` also reaches the panel as `{ state: 'error', error: { code, message } }`, with a Retry button. See the [error reference](/errors). |
0 commit comments