---
title: "The Standard Handler"
description: "initDevframe() turns a DevframeDefinition into a running devframe whose .handler (a Web Standard (request: Request) => Promise<Response>) carries everything a devframe serves (SPA, __connection.json discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path (adapters,…"
canonical_url: "https://devfra.me/adapters/initiate"
---
# The Standard Handler

> initDevframe() turns a DevframeDefinition into a running devframe whose .handler (a Web Standard (request: Request) => Promise\<Response>) carries everything a devframe serves (SPA, \_\_connection.json discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path (adapters,…

`initDevframe()` turns a `DevframeDefinition` into a running devframe whose `.handler` (a Web Standard `(request: Request) => Promise<Response>`) carries everything a devframe serves (SPA, `__connection.json` discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path ([adapters](https://devfra.me/adapters), [framework kits](https://devfra.me/frameworks), [hub](https://devfra.me/guide/hub-initiate)) is assembled from it. Mount it with a catch-all route.

```ts
import { initDevframe } from 'devframe/initiate'
import myDevframe from './my-tool'

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, and the running devframe echoes it back as `devtools.base`. `handler`/`nodeMiddleware` await readiness internally. The running devframe binds no port, so [the WebSocket binding](#the-websocket-binding) is the host framework's call.

## Mount the handler

::code-group
```ts [Vite]
// 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 './my-tool'

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)
      },
    }
  ],
})
```

```ts [Nitro]
// 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))
```

```ts [Hono]
// 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 }))
```

```ts [Next.js]
// 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 '@/my-tool'

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
```

```ts [Nuxt]
// 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))
})
```

```ts [SvelteKit]
// src/routes/%5F_my-tool/[...path]/+server.ts
import myDevframe from '$lib/my-tool'
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)
```
::

Host frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the calling module, so memoize the running devframe on `globalThis` to avoid leaking a socket per reload. `@devframes/next`'s `createDevframeNextHandler` handles this.

## The WebSocket binding

Fetch handlers only hand over `Request`s, so the host framework binds the RPC socket. The **local binding** resolves in this order:

1. **`ws.port`**: a side-car server on that exact port.
2. **`server`**: share the host framework's `node:http` server; the upgrade binds at `<base>__ws`. No extra ports.
3. **`ws: { sidecar: true }`**: a side-car server on a free port, for host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild).
4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. To run your own check before the socket opens, own the listener, match the `<base>__ws` path first (the listener sees every upgrade on the server, including the host framework's own), and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup.

The browser dials a side-car on its page's own hostname, so a side-car on the default `localhost` host listens on both `127.0.0.1` and `::1`. Pages opened on either address reach it.

`ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe'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 running devframe to take over the host framework's upgrades reports `DF0055` (a local binding owns the socket) or `DF0056` (`ws.url` handed it off).

## Auth

The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner when an untrusted browser client asks for a code (`rpc.requestAuthCode()`); an already-authorized page triggers no print. The magic link's origin comes from the `origin` option, or is derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. 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`](https://devfra.me/guide/hub-initiate).


## Sitemap

See the full [sitemap](https://devfra.me/sitemap.md) for all pages.
