Opt-in DevTools with Onboarding
@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.
Why
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.
What the user sees
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:
- 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.
- Hide for now removes the button for the current browser tab.
- Disable entirely writes a state file so the host stops injecting the button on every later start.
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.
Create the onboarding
import { createOnboarding } from '@devframes/hub-ui-onboard'
const onboarding = createOnboarding({
packages: ['@devframes/hub', '@devframes/hub-ui'],
branding: { productName: 'My DevTools', logo: '/logo.svg', primaryColor: '#646cff' },
})createOnboarding() returns four things:
handler(request): a web-standardRequest => Responsehandler for every path underbase(default/__devframes/).nodeMiddleware(req, res, next): the same handler as Connect middleware for Vite, Express, Fastify with@fastify/middie, or a plainnode:httpserver. It callsnext()for paths outsidebase.scriptSrc:<base>embedded.js, the URL to inject as<script type="module">.disabledandinstalled: what the host needs to decide whether to inject the script at all (below).
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.
Hand the base to the hub
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.
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const require = createRequire(join(cwd, 'package.json'))
const load = <T>(id: string): Promise<T> => import(pathToFileURL(require.resolve(id)).href)
const onboarding = createOnboarding({
cwd,
packages: ['@devframes/hub', '@devframes/hub-ui'],
async onInstalled() {
const [{ initHub }, { createUi }] = await Promise.all([
load<typeof import('@devframes/hub/initiate')>('@devframes/hub/initiate'),
load<typeof import('@devframes/hub-ui')>('@devframes/hub-ui'),
])
const hub = initHub({ base: '/__devframes/', cwd, server: httpServer, ui: createUi(), devframes: [] })
return hub.handler
},
})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.
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.
Without onInstalled, or when it returns nothing, the panel reports the install and asks for a restart. The next start finds the packages installed.
When to inject the button
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.
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.
Hosts
Vite
A plugin mounts the middleware and injects the tag. examples/hub-onboard-vite is the complete version with the hub hand-off.
import type { Plugin } from 'vite'
import { createOnboarding } from '@devframes/hub-ui-onboard'
function hubOnboarding(): Plugin {
const onboarding = createOnboarding({ packages: ['@devframes/hub', '@devframes/hub-ui'] })
return {
name: 'hub-onboarding',
apply: 'serve',
configureServer(server) {
server.middlewares.use(onboarding.nodeMiddleware)
},
transformIndexHtml() {
return onboarding.disabled
? []
: [{ tag: 'script', attrs: { type: 'module', src: onboarding.scriptSrc }, injectTo: 'body' }]
},
}
}Nuxt
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.
import { createOnboarding } from '@devframes/hub-ui-onboard'
import { addVitePlugin, defineNuxtModule } from '@nuxt/kit'
export default defineNuxtModule({
setup(_, nuxt) {
if (!nuxt.options.dev)
return
const onboarding = createOnboarding({
cwd: nuxt.options.rootDir,
packages: ['@nuxt/devtools'],
branding: { productName: 'Nuxt DevTools', primaryColor: '#00dc82' },
})
addVitePlugin({
name: 'devtools-onboarding',
configureServer: server => server.middlewares.use(onboarding.nodeMiddleware),
})
if (!onboarding.disabled)
(nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc })
},
})Next.js
A route handler forwards Request objects, and the root layout renders the tag.
import { onboarding } from '../../../devtools-onboarding'
export const GET = (request: Request) => onboarding.handler(request)
export const POST = (request: Request) => onboarding.handler(request)import { onboarding } from '../devtools-onboarding'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{process.env.NODE_ENV === 'development' && !onboarding.disabled && (
<script type="module" src={onboarding.scriptSrc} />
)}
</body>
</html>
)
}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.
Any Node server
import { createServer } from 'node:http'
createServer((req, res) => {
onboarding.nodeMiddleware(req, res, () => {
res.statusCode = 404
res.end()
})
}).listen(3000)Frameworks with a Request => Response surface (Hono, Nitro, Deno) mount onboarding.handler under base instead.
Strings and branding
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.
Errors
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.
Build Your Own Hub UI
A hub UI provider implements two contracts, the node-side ui slot and the browser-side context. @devframes/hub-ui is the reference.
Adapters
The lowest-level path is the standard handler, initDevframe(def, { base }): a Web Standard (request: Request) => Promise<Response> for any catch-all route. Every path below builds on it.