The Standard Handler
initDevframe() turns a DevframeDefinition into a live instance whose .handler — a Web Standard (request: Request) => Promise<Response> — carries the entire surface (SPA, __connection.json discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path — adapters, framework packages, hub — is assembled from it. Mount it with a catch-all route.
import { initDevframe } from 'devframe/initiate'
import myDevframe from './devframe'
const devtools = initDevframe(myDevframe, { base: '/__my-tool/' })
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.attach,
// devtools.handleUpgrade, devtools.ready, devtools.context,
// devtools.connectionMeta(), devtools.close()base is required — pass resolveBasePath(def, 'hosted') (def.basePath ?? /__<id>/) to default it; the instance echoes it back as devtools.base. handler/nodeMiddleware await readiness internally. The instance binds no port — the WebSocket binding is the host's call.
Mount the handler
// vite.config.ts
// connect-style middleware + Vite's own server for the socket
import { initDevframe } from 'devframe/initiate'
import { defineConfig } from 'vite'
import myDevframe from './devframe'
export default defineConfig({
plugins: [{
name: 'my-tool',
apply: 'serve',
configureServer(server) {
const devtools = initDevframe(myDevframe, {
base: '/__my-tool/',
server: server.httpServer ?? undefined,
})
server.middlewares.use(devtools.nodeMiddleware)
},
}],
})// routes/__my-tool/[...path].ts
// routes/__my-tool/index.ts
// for the namespace root, since a catch-all doesn't match its own empty path.
import { defineHandler } from 'nitro'
import { devtools } from '../../devtools'
export default defineHandler(event => devtools.handler(event.req))// server.ts
// `serve()` hands back the node server the socket rides on
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { devtools } from './devtools'
const app = new Hono()
app.all('/__my-tool/*', c => devtools.handler(c.req.raw))
devtools.attach(serve({ fetch: app.fetch, port: 3000 }))// app/%5F_my-tool/[[...path]]/route.ts
// Next reserves `_`-prefixed folders, so the segment is URL-encoded (`%5F_` decodes to `__`).
import { initDevframe } from 'devframe/initiate'
import myDevframe from '@/devframe'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// Route handlers never see upgrades, so the socket asks for a side-car; the
// globalThis memo keeps a dev-time reload from starting a second one.
const g = globalThis as { devtools?: ReturnType<typeof initDevframe> }
const devtools = g.devtools ??= initDevframe(myDevframe, {
base: '/__my-tool/',
ws: { sidecar: true },
})
export const GET = devtools.handler// server/middleware/devtools.ts
import { devtools } from '../devtools'
export default defineEventHandler((event) => {
const { pathname } = new URL(toWebRequest(event).url)
// `devtools.base` is the normalized mount base — no repeated string.
if (pathname.startsWith(devtools.base) || pathname === devtools.base.slice(0, -1))
return devtools.handler(toWebRequest(event))
})// src/routes/%5F_my-tool/[...path]/+server.ts
import myDevframe from '$lib/devframe'
import { initDevframe } from 'devframe/initiate'
const g = globalThis as { devtools?: ReturnType<typeof initDevframe> }
const devtools = g.devtools ??= initDevframe(myDevframe, {
base: '/__my-tool/',
ws: { sidecar: true },
})
export const GET = ({ request }) => devtools.handler(request)Frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the calling module, so memoize the instance on globalThis to avoid leaking a socket per reload. @devframes/next's createDevframeNextHandler handles this.
The WebSocket binding
Fetch handlers only hand over Requests, so the host binds the RPC socket. The local binding resolves in this order:
ws.port— a side-car server on that exact port.server— share the host'snode:httpserver; the upgrade binds at<base>__ws. No extra ports.ws: { sidecar: true }— a side-car server on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild).- The host's own upgrades — with none set, the socket waits:
devtools.attach(server)routes a server'supgradeevents (returning a detach fn);devtools.handleUpgrade(req, socket, head)completes a single one from a listener you own.
ws.url controls the advertisement instead — the browser dials it verbatim. Alone, an external server owns the transport and its auth (wire the instance's context via createContextRpcServer + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern).
__connection.json describes the active combination. Asking a configured instance to take over host upgrades reports DF0055 (a local binding owns the socket) or DF0056 (ws.url handed it off).
Auth
The instance gates by default. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known (the first request, or the origin option). Pass auth: false for single-user localhost, or a DevframeAuthHandler for a custom scheme.
Relation to the other adapters
createDevServer, devframeViteBridge (@devframes/vite), and @devframes/next are assembled from it internally. To host many devframes, use initHub.