Skip to content

Commit a91a26e

Browse files
committed
feat(next): add @devframes/next host-integration spike
Serve mounted devframe SPAs and connection meta from a Next.js App Router route via a single WHATWG-fetch handler that reuses devframe's serveStaticHandler, closing the Nuxt/Next bridge parity gap. Refactors the minimal-next-devframe-hub example onto the bridge, removing ~200 lines of hand-rolled static serving and registries. Implements plan 027 (spike); proposal in plans/notes/devframes-next-proposal.md.
1 parent f39cae8 commit a91a26e

15 files changed

Lines changed: 613 additions & 171 deletions

File tree

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
}

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 32 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { DevframeNextHost } from '@devframes/next'
23
import type { StartedServer } from 'devframe/node'
3-
import type { ConnectionMeta, DevframeDefinition, DevframeHost } from 'devframe/types'
4+
import type { ConnectionMeta, DevframeDefinition } from 'devframe/types'
45
import { homedir } from 'node:os'
56
import process from 'node:process'
67
import { defineHubRpcFunction } from '@devframes/hub'
78
import { createHubContext, mountDevframe } from '@devframes/hub/node'
89
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
9-
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
10+
import { createDevframeNextHost } from '@devframes/next'
1011
import { startHttpAndWs } from 'devframe/node'
1112
import { getPort } from 'get-port-please'
1213
import { createDashboardView } from 'minimal-json-render/dashboard'
@@ -79,44 +80,6 @@ async function loadA11yAgentMount(): Promise<A11yAgentMount | null> {
7980
}
8081
}
8182

82-
const STATIC_MOUNTS = new Map<string, string>()
83-
84-
export interface StaticMountHit {
85-
distDir: string
86-
relative: string
87-
}
88-
89-
export function getStaticMount(pathname: string): StaticMountHit | null {
90-
let best: { base: string, distDir: string } | null = null
91-
for (const [base, distDir] of STATIC_MOUNTS) {
92-
if (pathname === base || pathname.startsWith(`${base}/`)) {
93-
if (!best || base.length > best.base.length)
94-
best = { base, distDir }
95-
}
96-
}
97-
if (!best)
98-
return null
99-
const relative = pathname.slice(best.base.length) || '/'
100-
return { distDir: best.distDir, relative }
101-
}
102-
103-
// Bases (without trailing slash, e.g. `/__git`) under which the catch-all
104-
// route should serve the hub's connection meta at `<base>/__connection.json`.
105-
const CONNECTION_META_BASES = new Set<string>()
106-
const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}`
107-
108-
/**
109-
* If `pathname` is a `<base>/__connection.json` request for a base the hub
110-
* registered via `DevframeHost.mountConnectionMeta`, return that base;
111-
* otherwise `null`. The catch-all route uses this to answer the connection-meta
112-
* fetch a mounted devframe SPA makes from inside its iframe.
113-
*/
114-
export function isConnectionMetaPath(pathname: string): boolean {
115-
if (!pathname.endsWith(META_SUFFIX))
116-
return false
117-
return CONNECTION_META_BASES.has(pathname.slice(0, -META_SUFFIX.length))
118-
}
119-
12083
export interface MinimalNextDevframeHubOptions {
12184
/** Preferred port for the side-car RPC/WS server. Default: a free port near 9877. */
12285
port?: number
@@ -131,6 +94,12 @@ export interface MinimalNextDevframeHubOptions {
13194
export interface StartedMinimalNextDevframeHub extends StartedServer {
13295
context: DevframeHubContext
13396
connectionMeta: ConnectionMeta & { backend: 'websocket', websocket: number }
97+
/**
98+
* The bridge's WHATWG-`fetch` handler. Both the catch-all SPA route and the
99+
* `__hub/__connection.json` route delegate to it — it serves every mounted
100+
* SPA and answers `<base>/__connection.json` for the hub and each devframe.
101+
*/
102+
fetch: DevframeNextHost['fetch']
134103
}
135104

136105
const minimalNextHubMessagesList = defineHubRpcFunction({
@@ -166,27 +135,26 @@ export async function minimalNextDevframeHub(
166135
const cwd = options.cwd ?? process.cwd()
167136
const hostName = options.host ?? 'localhost'
168137

169-
const host: DevframeHost = {
170-
mountStatic(base, distDir) {
171-
STATIC_MOUNTS.set(base.replace(/\/$/, ''), distDir)
172-
},
173-
// Record the base so the catch-all route can answer `<base>/__connection.json`
174-
// with the hub's connection meta — letting the mounted SPA connect without
175-
// relying on same-origin parent-window inheritance.
176-
mountConnectionMeta(base) {
177-
CONNECTION_META_BASES.add(base.replace(/\/$/, ''))
178-
},
179-
resolveOrigin() {
180-
return `http://${hostName}:3000`
181-
},
138+
// The Next host bridge: its `host` accumulates every `mountStatic` /
139+
// `mountConnectionMeta` call into a single `fetch` handler (backed by
140+
// devframe's shared `serveStaticHandler`), which the App Router routes
141+
// delegate to — no hand-rolled static serving or path matching here.
142+
const nextHost = createDevframeNextHost({
143+
resolveOrigin: () => `http://${hostName}:3000`,
182144
getStorageDir(scope) {
183145
if (scope === 'workspace')
184146
return join(cwd, '.devframe')
185147
if (scope === 'project')
186148
return join(cwd, 'node_modules/.minimal-next-devframe-hub')
187149
return join(homedir(), '.minimal-next-devframe-hub')
188150
},
189-
}
151+
})
152+
const host = nextHost.host
153+
154+
// Register the hub's own connection base so the hub client (app/page.tsx)
155+
// can discover the side-car WS via `<base>/__connection.json`; the mounted
156+
// devframes each register their own base through `mountDevframe`.
157+
host.mountConnectionMeta?.('/__hub')
190158

191159
const port = options.port ?? await getPort({ host: hostName, port: 9877, random: false })
192160

@@ -277,12 +245,18 @@ export async function minimalNextDevframeHub(
277245
auth: false,
278246
})
279247

248+
const connectionMeta = {
249+
backend: 'websocket' as const,
250+
websocket: started.port,
251+
}
252+
// Publish the live meta to the bridge now the WS port is known, so every
253+
// registered `<base>/__connection.json` (hub + mounted devframes) resolves.
254+
nextHost.setConnectionMeta(connectionMeta)
255+
280256
return Object.assign(started, {
281257
context,
282-
connectionMeta: {
283-
backend: 'websocket' as const,
284-
websocket: started.port,
285-
},
258+
connectionMeta,
259+
fetch: nextHost.fetch,
286260
})
287261
}
288262

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { withDevframe } from '@devframes/next'
2+
3+
// `withDevframe` applies the settings a devframe host requires (currently
4+
// `skipTrailingSlashRedirect: true`, so mounted SPAs' relative assets under
5+
// `/__<id>/` resolve instead of 404-ing on Next's trailing-slash redirect).
16
/** @type {import('next').NextConfig} */
2-
const nextConfig = {
7+
const nextConfig = withDevframe({
38
images: { unoptimized: true },
49
// @antfu/design ships raw, uncompiled `.ts`/`.vue` source (see its README —
510
// "no bundling, your build compiles it"). `dockIconSvg` (design/dock-icon.ts)
@@ -9,12 +14,6 @@ const nextConfig = {
914
transpilePackages: ['@antfu/design'],
1015
// The workspace typecheck owns source-level project references.
1116
typescript: { ignoreBuildErrors: true },
12-
// Mounted devframe SPAs are served at `/__<id>/` and reference their assets
13-
// relatively (`./_next/…`, `./assets/…`). Next's default trailing-slash
14-
// redirect (`/__git/` → `/__git`) would re-root those relative paths and 404
15-
// every asset, leaving the panel unstyled and unable to connect. Serving the
16-
// base path verbatim keeps the SPA's relative asset resolution intact.
17-
skipTrailingSlashRedirect: true,
18-
}
17+
})
1918

2019
export default nextConfig

packages/next/package.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "@devframes/next",
3+
"type": "module",
4+
"version": "0.7.14",
5+
"private": true,
6+
"description": "Next.js host integration for Devframe — serve mounted devframe SPAs and connection meta from an App Router route (SPIKE / proposed, plan 027)",
7+
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
8+
"license": "MIT",
9+
"homepage": "https://github.com/devframes/devframe#readme",
10+
"repository": {
11+
"directory": "packages/next",
12+
"type": "git",
13+
"url": "git+https://github.com/devframes/devframe.git"
14+
},
15+
"bugs": "https://github.com/devframes/devframe/issues",
16+
"keywords": [
17+
"devframe",
18+
"next",
19+
"nextjs",
20+
"devtools"
21+
],
22+
"sideEffects": false,
23+
"exports": {
24+
".": {
25+
"types": "./dist/index.d.mts",
26+
"default": "./dist/index.mjs"
27+
},
28+
"./package.json": "./package.json"
29+
},
30+
"types": "./dist/index.d.mts",
31+
"files": [
32+
"dist"
33+
],
34+
"scripts": {
35+
"build": "tsdown",
36+
"watch": "tsdown --watch",
37+
"typecheck": "tsc --noEmit",
38+
"prepack": "pnpm run build"
39+
},
40+
"peerDependencies": {
41+
"devframe": "workspace:*",
42+
"next": "^14.0.0 || ^15.0.0"
43+
},
44+
"peerDependenciesMeta": {
45+
"next": {
46+
"optional": true
47+
}
48+
},
49+
"dependencies": {
50+
"h3": "catalog:deps"
51+
},
52+
"devDependencies": {
53+
"@types/node": "catalog:types",
54+
"devframe": "workspace:*",
55+
"tsdown": "catalog:build"
56+
}
57+
}

0 commit comments

Comments
 (0)