Skip to content

Commit 51c8b29

Browse files
authored
feat(next): @devframes/next — Next.js host integration (experimental package) (#140)
1 parent a88350d commit 51c8b29

31 files changed

Lines changed: 951 additions & 304 deletions

File tree

alias.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export const alias = {
4646
'@devframes/hub': r('hub/src/index.ts'),
4747
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
4848
'@devframes/nuxt': r('nuxt/src/index.ts'),
49+
'@devframes/next/client': r('next/src/client.tsx'),
50+
'@devframes/next': r('next/src/index.ts'),
4951
'@devframes/json-render/core': r('json-render/src/core.ts'),
5052
'@devframes/json-render/hub': r('json-render/src/hub.ts'),
5153
'@devframes/json-render/node': r('json-render/src/node/index.ts'),

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function helpersItems(prefix: string) {
5555
{ text: 'Utilities', link: `${prefix}/helpers/utilities` },
5656
{ text: 'Vite Bridge', link: `${prefix}/helpers/vite-bridge` },
5757
{ text: 'Nuxt Module', link: `${prefix}/helpers/nuxt` },
58+
{ text: 'Next Helper', link: `${prefix}/helpers/next` },
5859
{ text: 'Open Helpers', link: `${prefix}/helpers/open-helpers` },
5960
{ text: 'Interactive Auth', link: `${prefix}/helpers/interactive-auth` },
6061
] satisfies DefaultTheme.NavItemWithLink[]

docs/examples/minimal-next-devframe-hub.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)**
1111
## What it proves
1212

1313
- `createHubContext()` boots a hub without any Vite-specific code path.
14-
- A `DevframeHost` implementation plugs the Next host specifics into the hub uniformly.
14+
- 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.
1515
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock.
1616
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
1717
- 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.
@@ -27,6 +27,10 @@ pnpm --filter minimal-next-devframe-hub dev
2727

2828
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`.
2929

30+
## See also
31+
32+
- [Next Helper](/helpers/next) — the `@devframes/next` host bridge this example runs on
33+
3034
## Source
3135

3236
[`examples/minimal-next-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub)

docs/helpers/index.md

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

docs/helpers/next.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Next Helper
6+
7+
> [!WARNING]
8+
> Experimental. `@devframes/next`'s API is still settling — expect changes before a stable release.
9+
10+
`@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.
11+
12+
It comes in three parts:
13+
14+
1. **`withDevframe()`** — applies the one Next config setting a devframe host needs.
15+
2. **`createDevframeNextHandler()`** — hosts a single devframe (the common case).
16+
3. **`createDevframeNextHost()`** — the lower-level primitive for a hub mounting many devframes at once.
17+
18+
Plus a React client surface at `@devframes/next/client`.
19+
20+
## Config
21+
22+
```ts [next.config.mjs]
23+
import { withDevframe } from '@devframes/next'
24+
25+
export default withDevframe({
26+
// ...your own Next config
27+
})
28+
```
29+
30+
`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.
31+
32+
## Hosting a single devframe
33+
34+
`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`:
35+
36+
```ts [app/__my-tool/[[...path]]/route.ts]
37+
import { createDevframeNextHandler } from '@devframes/next'
38+
import myDevframe from '@/devframe'
39+
40+
export const runtime = 'nodejs'
41+
export const dynamic = 'force-dynamic'
42+
43+
const handler = createDevframeNextHandler(myDevframe)
44+
export const GET = handler.fetch
45+
```
46+
47+
The base defaults to `def.basePath ?? '/__<id>/'`. `close()` shuts the side-car down; `ready` resolves once it's listening.
48+
49+
| Option | Default | Description |
50+
|--------|---------|-------------|
51+
| `base` | `def.basePath ?? '/__<id>/'` | Mount path for the SPA. |
52+
| `host` | `def.cli?.host ?? 'localhost'` | Side-car bind host. |
53+
| `port` | resolved from `def.cli?.port` | Side-car port. |
54+
| `flags` || Forwarded to `def.setup(ctx, { flags })`. |
55+
| `auth` | `false` | `true` for devframe's OTP gate, or a handler. The Next app owns auth by default. |
56+
57+
## Hosting a hub
58+
59+
For many devframes at once, use `createDevframeNextHost()` with [`@devframes/hub`](/guide/hub). Its `host` accumulates every `mountStatic` / `mountConnectionMeta` call into one `fetch` handler:
60+
61+
```ts [devframe/host.ts]
62+
import { createHubContext, mountDevframe } from '@devframes/hub/node'
63+
import { createDevframeNextHost } from '@devframes/next'
64+
import { startHttpAndWs } from 'devframe/node'
65+
66+
const nextHost = createDevframeNextHost({
67+
resolveOrigin: () => 'http://localhost:3000',
68+
getStorageDir: scope => resolveStorageDir(scope),
69+
})
70+
71+
const context = await createHubContext({ host: nextHost.host, mode: 'dev' })
72+
await mountDevframe(context, myDevframe)
73+
nextHost.host.mountConnectionMeta('/__hub') // the hub's own connection base
74+
75+
const started = await startHttpAndWs({ context, port, auth: false })
76+
nextHost.setConnectionMeta({ backend: 'websocket', websocket: started.port })
77+
78+
export const hub = { fetch: nextHost.fetch }
79+
```
80+
81+
```ts [app/__[id]/[[...path]]/route.ts]
82+
export const runtime = 'nodejs'
83+
export const dynamic = 'force-dynamic'
84+
85+
export async function GET(request: Request): Promise<Response> {
86+
return hub.fetch(request) // serves every mounted SPA + connection meta
87+
}
88+
```
89+
90+
`createDevframeNextHost` returns `{ host, fetch, setConnectionMeta }`:
91+
92+
- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`.
93+
- **`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`.
94+
- **`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.
95+
96+
## React client
97+
98+
`@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.
99+
100+
```tsx [app/providers.tsx]
101+
'use client'
102+
import { RpcProvider } from '@devframes/next/client'
103+
104+
export function Providers({ children }: { children: React.ReactNode }) {
105+
return <RpcProvider baseURL="/__my-tool/">{children}</RpcProvider>
106+
}
107+
```
108+
109+
`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.
110+
111+
```tsx [app/panel.tsx]
112+
'use client'
113+
import { useRpc, useRpcStatus } from '@devframes/next/client'
114+
115+
export function Panel() {
116+
const rpc = useRpc()?.scope('my-tool:')
117+
const { status, error } = useRpcStatus()
118+
if (!rpc)
119+
return <p>{error ? `connection failed — ${error.message}` : 'connecting…'}</p>
120+
// rpc.rpc.call('get-payload'), rpc.sharedState, …
121+
}
122+
```
123+
124+
Both hooks throw outside a `<RpcProvider>`. Theming and layout stay app-owned.
125+
126+
## Runtime
127+
128+
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.
129+
130+
## See also
131+
132+
- [Vite Bridge](./vite-bridge) — the equivalent for Vite-based hosts
133+
- [Hub](/guide/hub)`createHubContext`, `mountDevframe`, and `DevframeHost`
134+
- [minimal-next-devframe-hub](/examples/minimal-next-devframe-hub) — a full working host

examples/minimal-next-devframe-hub/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@antfu/design": "catalog:frontend",
1515
"@devframes/hub": "workspace:*",
1616
"@devframes/json-render": "workspace:*",
17+
"@devframes/next": "workspace:*",
1718
"@devframes/plugin-a11y": "workspace:*",
1819
"@devframes/plugin-code-server": "workspace:*",
1920
"@devframes/plugin-git": "workspace:*",
Lines changed: 9 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,16 @@
1-
import { createReadStream } from 'node:fs'
2-
import { stat } from 'node:fs/promises'
3-
import { Readable } from 'node:stream'
4-
import { extname, join, normalize, resolve, sep } from 'pathe'
5-
import { ensureMinimalNextDevframeHub, getStaticMount, isConnectionMetaPath } from '../../../devframe/minimal-next-devframe-hub'
1+
import { ensureMinimalNextDevframeHub } from '../../../devframe/minimal-next-devframe-hub'
62

73
export const runtime = 'nodejs'
84
export const dynamic = 'force-dynamic'
95

10-
const CONTENT_TYPES: Record<string, string> = {
11-
'.html': 'text/html; charset=utf-8',
12-
'.htm': 'text/html; charset=utf-8',
13-
'.css': 'text/css; charset=utf-8',
14-
'.js': 'application/javascript; charset=utf-8',
15-
'.mjs': 'application/javascript; charset=utf-8',
16-
'.json': 'application/json; charset=utf-8',
17-
'.svg': 'image/svg+xml',
18-
'.png': 'image/png',
19-
'.jpg': 'image/jpeg',
20-
'.jpeg': 'image/jpeg',
21-
'.gif': 'image/gif',
22-
'.webp': 'image/webp',
23-
'.ico': 'image/x-icon',
24-
}
25-
26-
interface ResolvedFile {
27-
abs: string
28-
size: number
29-
mtime: Date
30-
}
31-
32-
async function statFile(abs: string): Promise<ResolvedFile | null> {
33-
try {
34-
const s = await stat(abs)
35-
if (!s.isFile())
36-
return null
37-
return { abs, size: s.size, mtime: s.mtime }
38-
}
39-
catch {
40-
return null
41-
}
42-
}
43-
44-
async function resolveTarget(absDir: string, urlPath: string): Promise<ResolvedFile | null> {
45-
let cleaned = decodeURIComponent(urlPath || '/').replace(/[?#].*$/, '')
46-
if (cleaned.endsWith('/'))
47-
cleaned = cleaned.slice(0, -1)
48-
if (cleaned.startsWith('/'))
49-
cleaned = cleaned.slice(1)
50-
51-
const abs = normalize(join(absDir, cleaned))
52-
if (abs !== absDir && !abs.startsWith(absDir + sep))
53-
return null
54-
55-
const direct = await statFile(abs)
56-
if (direct)
57-
return direct
58-
59-
// Directory → index.html
60-
try {
61-
const s = await stat(abs)
62-
if (s.isDirectory()) {
63-
const candidate = await statFile(join(abs, 'index.html'))
64-
if (candidate)
65-
return candidate
66-
}
67-
}
68-
catch {
69-
// not found / not a directory — continue
70-
}
71-
72-
// SPA fallback for extensionless paths
73-
if (!/\.[a-z0-9]+$/i.test(cleaned)) {
74-
const fallback = await statFile(join(absDir, 'index.html'))
75-
if (fallback)
76-
return fallback
77-
}
78-
79-
return null
80-
}
81-
6+
/**
7+
* Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the
8+
* a11y agent module, …) and their `<base>/__connection.json` discovery fetches.
9+
* The `@devframes/next` bridge owns all of it — static serving (with SPA
10+
* fallback, content types, and traversal guarding via devframe's shared
11+
* `serveStaticHandler`) and the connection-meta responses.
12+
*/
8213
export async function GET(request: Request): Promise<Response> {
8314
const hub = await ensureMinimalNextDevframeHub()
84-
85-
const pathname = new URL(request.url).pathname
86-
87-
// A mounted devframe SPA fetches `<base>/__connection.json` to discover the
88-
// side-car WS endpoint. Answer it with the hub's connection meta.
89-
if (isConnectionMetaPath(pathname))
90-
return Response.json(hub.connectionMeta)
91-
92-
const hit = getStaticMount(pathname)
93-
if (!hit)
94-
return new Response(null, { status: 404 })
95-
96-
const file = await resolveTarget(resolve(hit.distDir), hit.relative)
97-
if (!file)
98-
return new Response(null, { status: 404 })
99-
100-
return new Response(Readable.toWeb(createReadStream(file.abs)) as ReadableStream, {
101-
status: 200,
102-
headers: {
103-
'Content-Type': CONTENT_TYPES[extname(file.abs).toLowerCase()] ?? 'application/octet-stream',
104-
'Content-Length': String(file.size),
105-
'Last-Modified': file.mtime.toUTCString(),
106-
'Cache-Control': 'no-store',
107-
},
108-
})
15+
return hub.fetch(request)
10916
}

examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { ensureMinimalNextDevframeHub } from '../../../devframe/minimal-next-dev
33
export const runtime = 'nodejs'
44
export const dynamic = 'force-dynamic'
55

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

0 commit comments

Comments
 (0)