Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const alias = {
'@devframes/hub': r('hub/src/index.ts'),
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
'@devframes/nuxt': r('nuxt/src/index.ts'),
'@devframes/next/client': r('next/src/client.tsx'),
'@devframes/next': r('next/src/index.ts'),
'@devframes/json-render/core': r('json-render/src/core.ts'),
'@devframes/json-render/hub': r('json-render/src/hub.ts'),
'@devframes/json-render/node': r('json-render/src/node/index.ts'),
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function helpersItems(prefix: string) {
{ text: 'Utilities', link: `${prefix}/helpers/utilities` },
{ text: 'Vite Bridge', link: `${prefix}/helpers/vite-bridge` },
{ text: 'Nuxt Module', link: `${prefix}/helpers/nuxt` },
{ text: 'Next Helper', link: `${prefix}/helpers/next` },
{ text: 'Open Helpers', link: `${prefix}/helpers/open-helpers` },
{ text: 'Interactive Auth', link: `${prefix}/helpers/interactive-auth` },
] satisfies DefaultTheme.NavItemWithLink[]
Expand Down
6 changes: 5 additions & 1 deletion docs/examples/minimal-next-devframe-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)**
## What it proves

- `createHubContext()` boots a hub without any Vite-specific code path.
- A `DevframeHost` implementation plugs the Next host specifics into the hub uniformly.
- The [`@devframes/next`](/helpers/next) bridge supplies the `DevframeHost` and a single `fetch` handler both route handlers delegate to — serving every mounted SPA and its connection meta through devframe's own static handler.
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock.
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`, which starts the singleton host on demand.
Expand All @@ -27,6 +27,10 @@ pnpm --filter minimal-next-devframe-hub dev

Open the printed URL to see the docks, commands, messages, and terminals lists, plus a button that dispatches a sample command through `hub:commands:execute`.

## See also

- [Next Helper](/helpers/next) — the `@devframes/next` host bridge this example runs on

## Source

[`examples/minimal-next-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub)
1 change: 1 addition & 0 deletions docs/helpers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Helpers are the optional, opt-in surface around the core `defineDevframe` API: s
| [Utilities](./utilities) | `devframe/utils/*` | Bundled small utilities — terminal colors, hashing, editor launch, structured-clone serialization, and more. |
| [Vite Bridge](./vite-bridge) | `devframe/helpers/vite` | Vite plugin for mounting a devframe inside any Vite-based host (Astro, SolidStart, plain Vite). |
| [Nuxt Module](./nuxt) | `@devframes/nuxt` | Nuxt module that wires a Nuxt SPA as a devframe client and serves the dev-time RPC bridge. |
| [Next Helper](./next) | `@devframes/next` | Route-handler host + React client for mounting devframes inside a Next.js App Router app (experimental). |
| [Open Helpers](./open-helpers) | `devframe/recipes/open-helpers` | Prebuilt RPC actions for "open in editor" and "reveal in Finder". |
| [Interactive Auth](./interactive-auth) | `devframe/recipes/interactive-auth` | Ready-made OTP auth layer — handshake, resolver gate, connect-time trust, and the code/link banner. |

Expand Down
134 changes: 134 additions & 0 deletions docs/helpers/next.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
outline: deep
---

# Next Helper

> [!WARNING]
> Experimental. `@devframes/next`'s API is still settling — expect changes before a stable release.

`@devframes/next` hosts devframes from a Next.js App Router app. Next runs on webpack/Turbopack rather than Vite, so it hosts through a route handler instead of the [Vite Bridge](./vite-bridge): the package serves each devframe's SPA and its `__connection.json` from a single `fetch` handler your catch-all route delegates to, reusing devframe's own [`serveStaticHandler`](/adapters/dev) for SPA fallback, content types, and path-traversal guarding.

It comes in three parts:

1. **`withDevframe()`** — applies the one Next config setting a devframe host needs.
2. **`createDevframeNextHandler()`** — hosts a single devframe (the common case).
3. **`createDevframeNextHost()`** — the lower-level primitive for a hub mounting many devframes at once.

Plus a React client surface at `@devframes/next/client`.

## Config

```ts [next.config.mjs]
import { withDevframe } from '@devframes/next'

export default withDevframe({
// ...your own Next config
})
```

`withDevframe` sets `skipTrailingSlashRedirect: true` and preserves the rest. Mounted SPAs are served at `/__<id>/` and reference their assets relatively (`./_next/…`); Next's default trailing-slash redirect (`/__git/` → `/__git`) would re-root those paths and 404 every asset, so a host serves the base verbatim.

## Hosting a single devframe

`createDevframeNextHandler(definition)` statically serves the devframe's built SPA and starts a side-car RPC/WebSocket server, advertising it at `<base>/__connection.json`. Delegate your catch-all route to its `fetch`:

```ts [app/__my-tool/[[...path]]/route.ts]
import { createDevframeNextHandler } from '@devframes/next'
import myDevframe from '@/devframe'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const handler = createDevframeNextHandler(myDevframe)
export const GET = handler.fetch
```

The base defaults to `def.basePath ?? '/__<id>/'`. `close()` shuts the side-car down; `ready` resolves once it's listening.

| Option | Default | Description |
|--------|---------|-------------|
| `base` | `def.basePath ?? '/__<id>/'` | Mount path for the SPA. |
| `host` | `def.cli?.host ?? 'localhost'` | Side-car bind host. |
| `port` | resolved from `def.cli?.port` | Side-car port. |
| `flags` | — | Forwarded to `def.setup(ctx, { flags })`. |
| `auth` | `false` | `true` for devframe's OTP gate, or a handler. The Next app owns auth by default. |

## Hosting a hub

For many devframes at once, use `createDevframeNextHost()` with [`@devframes/hub`](/guide/hub). Its `host` accumulates every `mountStatic` / `mountConnectionMeta` call into one `fetch` handler:

```ts [devframe/host.ts]
import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { createDevframeNextHost } from '@devframes/next'
import { startHttpAndWs } from 'devframe/node'

const nextHost = createDevframeNextHost({
resolveOrigin: () => 'http://localhost:3000',
getStorageDir: scope => resolveStorageDir(scope),
})

const context = await createHubContext({ host: nextHost.host, mode: 'dev' })
await mountDevframe(context, myDevframe)
nextHost.host.mountConnectionMeta('/__hub') // the hub's own connection base

const started = await startHttpAndWs({ context, port, auth: false })
nextHost.setConnectionMeta({ backend: 'websocket', websocket: started.port })

export const hub = { fetch: nextHost.fetch }
```

```ts [app/__[id]/[[...path]]/route.ts]
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET(request: Request): Promise<Response> {
return hub.fetch(request) // serves every mounted SPA + connection meta
}
```

`createDevframeNextHost` returns `{ host, fetch, setConnectionMeta }`:

- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`.
- **`fetch(request)`** — the handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a `<base>/__connection.json` discovery fetch; a miss returns a bare `404`.
- **`setConnectionMeta(meta)`** — publish the live meta once the RPC/WS port is known. Until then, meta requests answer `503` so a racing client retries rather than caching a wrong endpoint.

## React client

`@devframes/next/client` connects to the RPC backend and provides the client to your component tree — the React counterpart to `@devframes/nuxt`'s `$rpc` plugin. Children render immediately, so your shell and a connection indicator stay visible while the client connects.

```tsx [app/providers.tsx]
'use client'
import { RpcProvider } from '@devframes/next/client'

export function Providers({ children }: { children: React.ReactNode }) {
return <RpcProvider baseURL="/__my-tool/">{children}</RpcProvider>
}
```

`useRpc()` returns the connected `DevframeRpcClient`, or `null` while connecting; scope it to your tool's namespace. `useRpcStatus()` returns the live `{ status, error }` for a connection indicator.

```tsx [app/panel.tsx]
'use client'
import { useRpc, useRpcStatus } from '@devframes/next/client'

export function Panel() {
const rpc = useRpc()?.scope('my-tool:')
const { status, error } = useRpcStatus()
if (!rpc)
return <p>{error ? `connection failed — ${error.message}` : 'connecting…'}</p>
// rpc.rpc.call('get-payload'), rpc.sharedState, …
}
```

Both hooks throw outside a `<RpcProvider>`. Theming and layout stay app-owned.

## Runtime

Route handlers that call `fetch` pin `export const runtime = 'nodejs'`: the static handler streams built SPA files from disk, and the side-car RPC/WS server is a Node process.

## See also

- [Vite Bridge](./vite-bridge) — the equivalent for Vite-based hosts
- [Hub](/guide/hub) — `createHubContext`, `mountDevframe`, and `DevframeHost`
- [minimal-next-devframe-hub](/examples/minimal-next-devframe-hub) — a full working host
1 change: 1 addition & 0 deletions examples/minimal-next-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@antfu/design": "catalog:frontend",
"@devframes/hub": "workspace:*",
"@devframes/json-render": "workspace:*",
"@devframes/next": "workspace:*",
"@devframes/plugin-a11y": "workspace:*",
"@devframes/plugin-code-server": "workspace:*",
"@devframes/plugin-git": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,109 +1,16 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { extname, join, normalize, resolve, sep } from 'pathe'
import { ensureMinimalNextDevframeHub, getStaticMount, isConnectionMetaPath } from '../../../devframe/minimal-next-devframe-hub'
import { ensureMinimalNextDevframeHub } from '../../../devframe/minimal-next-devframe-hub'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
}

interface ResolvedFile {
abs: string
size: number
mtime: Date
}

async function statFile(abs: string): Promise<ResolvedFile | null> {
try {
const s = await stat(abs)
if (!s.isFile())
return null
return { abs, size: s.size, mtime: s.mtime }
}
catch {
return null
}
}

async function resolveTarget(absDir: string, urlPath: string): Promise<ResolvedFile | null> {
let cleaned = decodeURIComponent(urlPath || '/').replace(/[?#].*$/, '')
if (cleaned.endsWith('/'))
cleaned = cleaned.slice(0, -1)
if (cleaned.startsWith('/'))
cleaned = cleaned.slice(1)

const abs = normalize(join(absDir, cleaned))
if (abs !== absDir && !abs.startsWith(absDir + sep))
return null

const direct = await statFile(abs)
if (direct)
return direct

// Directory → index.html
try {
const s = await stat(abs)
if (s.isDirectory()) {
const candidate = await statFile(join(abs, 'index.html'))
if (candidate)
return candidate
}
}
catch {
// not found / not a directory — continue
}

// SPA fallback for extensionless paths
if (!/\.[a-z0-9]+$/i.test(cleaned)) {
const fallback = await statFile(join(absDir, 'index.html'))
if (fallback)
return fallback
}

return null
}

/**
* Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the
* a11y agent module, …) and their `<base>/__connection.json` discovery fetches.
* The `@devframes/next` bridge owns all of it — static serving (with SPA
* fallback, content types, and traversal guarding via devframe's shared
* `serveStaticHandler`) and the connection-meta responses.
*/
export async function GET(request: Request): Promise<Response> {
const hub = await ensureMinimalNextDevframeHub()

const pathname = new URL(request.url).pathname

// A mounted devframe SPA fetches `<base>/__connection.json` to discover the
// side-car WS endpoint. Answer it with the hub's connection meta.
if (isConnectionMetaPath(pathname))
return Response.json(hub.connectionMeta)

const hit = getStaticMount(pathname)
if (!hit)
return new Response(null, { status: 404 })

const file = await resolveTarget(resolve(hit.distDir), hit.relative)
if (!file)
return new Response(null, { status: 404 })

return new Response(Readable.toWeb(createReadStream(file.abs)) as ReadableStream, {
status: 200,
headers: {
'Content-Type': CONTENT_TYPES[extname(file.abs).toLowerCase()] ?? 'application/octet-stream',
'Content-Length': String(file.size),
'Last-Modified': file.mtime.toUTCString(),
'Cache-Control': 'no-store',
},
})
return hub.fetch(request)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { ensureMinimalNextDevframeHub } from '../../../devframe/minimal-next-dev
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET() {
/**
* The hub client (`app/page.tsx`) discovers the side-car WS endpoint here. The
* `@devframes/next` bridge answers it — `/__hub` is registered as a connection
* base in the host setup — so this delegates to the same `fetch` as the
* catch-all SPA route.
*/
export async function GET(request: Request): Promise<Response> {
const hub = await ensureMinimalNextDevframeHub()
return Response.json(hub.connectionMeta)
return hub.fetch(request)
}
Loading
Loading