From a91a26eb00511dd1d7c0f20db4853ba930569233 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 05:45:28 +0000 Subject: [PATCH 1/5] 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. --- .../minimal-next-devframe-hub/package.json | 1 + .../client/app/%5F_[id]/[[...path]]/route.ts | 111 +----------- .../app/%5F_hub/%5F_connection.json/route.ts | 10 +- .../devframe/minimal-next-devframe-hub.ts | 90 ++++------ .../src/client/next.config.mjs | 15 +- packages/next/package.json | 57 +++++++ packages/next/src/config.ts | 36 ++++ packages/next/src/host.ts | 124 ++++++++++++++ packages/next/src/index.ts | 8 + packages/next/tsconfig.json | 9 + packages/next/tsdown.config.ts | 10 ++ plans/README.md | 2 +- plans/notes/devframes-next-proposal.md | 158 ++++++++++++++++++ pnpm-lock.yaml | 148 ++++++++++++++++ turbo.json | 5 + 15 files changed, 613 insertions(+), 171 deletions(-) create mode 100644 packages/next/package.json create mode 100644 packages/next/src/config.ts create mode 100644 packages/next/src/host.ts create mode 100644 packages/next/src/index.ts create mode 100644 packages/next/tsconfig.json create mode 100644 packages/next/tsdown.config.ts create mode 100644 plans/notes/devframes-next-proposal.md diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index 2fb5b770..cc2164ce 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -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:*", diff --git a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts index 64092398..64210ba0 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts +++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts @@ -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 = { - '.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 { - 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 { - 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 `/__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 { const hub = await ensureMinimalNextDevframeHub() - - const pathname = new URL(request.url).pathname - - // A mounted devframe SPA fetches `/__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) } diff --git a/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts b/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts index 96724f74..41bcaf4c 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts +++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts @@ -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 { const hub = await ensureMinimalNextDevframeHub() - return Response.json(hub.connectionMeta) + return hub.fetch(request) } diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts index bf8cb2bb..899fcca4 100644 --- a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -1,12 +1,13 @@ import type { DevframeHubContext } from '@devframes/hub/node' +import type { DevframeNextHost } from '@devframes/next' import type { StartedServer } from 'devframe/node' -import type { ConnectionMeta, DevframeDefinition, DevframeHost } from 'devframe/types' +import type { ConnectionMeta, DevframeDefinition } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' -import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { createDevframeNextHost } from '@devframes/next' import { startHttpAndWs } from 'devframe/node' import { getPort } from 'get-port-please' import { createDashboardView } from 'minimal-json-render/dashboard' @@ -79,44 +80,6 @@ async function loadA11yAgentMount(): Promise { } } -const STATIC_MOUNTS = new Map() - -export interface StaticMountHit { - distDir: string - relative: string -} - -export function getStaticMount(pathname: string): StaticMountHit | null { - let best: { base: string, distDir: string } | null = null - for (const [base, distDir] of STATIC_MOUNTS) { - if (pathname === base || pathname.startsWith(`${base}/`)) { - if (!best || base.length > best.base.length) - best = { base, distDir } - } - } - if (!best) - return null - const relative = pathname.slice(best.base.length) || '/' - return { distDir: best.distDir, relative } -} - -// Bases (without trailing slash, e.g. `/__git`) under which the catch-all -// route should serve the hub's connection meta at `/__connection.json`. -const CONNECTION_META_BASES = new Set() -const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` - -/** - * If `pathname` is a `/__connection.json` request for a base the hub - * registered via `DevframeHost.mountConnectionMeta`, return that base; - * otherwise `null`. The catch-all route uses this to answer the connection-meta - * fetch a mounted devframe SPA makes from inside its iframe. - */ -export function isConnectionMetaPath(pathname: string): boolean { - if (!pathname.endsWith(META_SUFFIX)) - return false - return CONNECTION_META_BASES.has(pathname.slice(0, -META_SUFFIX.length)) -} - export interface MinimalNextDevframeHubOptions { /** Preferred port for the side-car RPC/WS server. Default: a free port near 9877. */ port?: number @@ -131,6 +94,12 @@ export interface MinimalNextDevframeHubOptions { export interface StartedMinimalNextDevframeHub extends StartedServer { context: DevframeHubContext connectionMeta: ConnectionMeta & { backend: 'websocket', websocket: number } + /** + * The bridge's WHATWG-`fetch` handler. Both the catch-all SPA route and the + * `__hub/__connection.json` route delegate to it — it serves every mounted + * SPA and answers `/__connection.json` for the hub and each devframe. + */ + fetch: DevframeNextHost['fetch'] } const minimalNextHubMessagesList = defineHubRpcFunction({ @@ -166,19 +135,12 @@ export async function minimalNextDevframeHub( const cwd = options.cwd ?? process.cwd() const hostName = options.host ?? 'localhost' - const host: DevframeHost = { - mountStatic(base, distDir) { - STATIC_MOUNTS.set(base.replace(/\/$/, ''), distDir) - }, - // Record the base so the catch-all route can answer `/__connection.json` - // with the hub's connection meta — letting the mounted SPA connect without - // relying on same-origin parent-window inheritance. - mountConnectionMeta(base) { - CONNECTION_META_BASES.add(base.replace(/\/$/, '')) - }, - resolveOrigin() { - return `http://${hostName}:3000` - }, + // The Next host bridge: its `host` accumulates every `mountStatic` / + // `mountConnectionMeta` call into a single `fetch` handler (backed by + // devframe's shared `serveStaticHandler`), which the App Router routes + // delegate to — no hand-rolled static serving or path matching here. + const nextHost = createDevframeNextHost({ + resolveOrigin: () => `http://${hostName}:3000`, getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') @@ -186,7 +148,13 @@ export async function minimalNextDevframeHub( return join(cwd, 'node_modules/.minimal-next-devframe-hub') return join(homedir(), '.minimal-next-devframe-hub') }, - } + }) + const host = nextHost.host + + // Register the hub's own connection base so the hub client (app/page.tsx) + // can discover the side-car WS via `/__connection.json`; the mounted + // devframes each register their own base through `mountDevframe`. + host.mountConnectionMeta?.('/__hub') const port = options.port ?? await getPort({ host: hostName, port: 9877, random: false }) @@ -277,12 +245,18 @@ export async function minimalNextDevframeHub( auth: false, }) + const connectionMeta = { + backend: 'websocket' as const, + websocket: started.port, + } + // Publish the live meta to the bridge now the WS port is known, so every + // registered `/__connection.json` (hub + mounted devframes) resolves. + nextHost.setConnectionMeta(connectionMeta) + return Object.assign(started, { context, - connectionMeta: { - backend: 'websocket' as const, - websocket: started.port, - }, + connectionMeta, + fetch: nextHost.fetch, }) } diff --git a/examples/minimal-next-devframe-hub/src/client/next.config.mjs b/examples/minimal-next-devframe-hub/src/client/next.config.mjs index 766add98..242dbabb 100644 --- a/examples/minimal-next-devframe-hub/src/client/next.config.mjs +++ b/examples/minimal-next-devframe-hub/src/client/next.config.mjs @@ -1,5 +1,10 @@ +import { withDevframe } from '@devframes/next' + +// `withDevframe` applies the settings a devframe host requires (currently +// `skipTrailingSlashRedirect: true`, so mounted SPAs' relative assets under +// `/__/` resolve instead of 404-ing on Next's trailing-slash redirect). /** @type {import('next').NextConfig} */ -const nextConfig = { +const nextConfig = withDevframe({ images: { unoptimized: true }, // @antfu/design ships raw, uncompiled `.ts`/`.vue` source (see its README — // "no bundling, your build compiles it"). `dockIconSvg` (design/dock-icon.ts) @@ -9,12 +14,6 @@ const nextConfig = { transpilePackages: ['@antfu/design'], // The workspace typecheck owns source-level project references. typescript: { ignoreBuildErrors: true }, - // Mounted devframe SPAs are served at `/__/` and reference their assets - // relatively (`./_next/…`, `./assets/…`). Next's default trailing-slash - // redirect (`/__git/` → `/__git`) would re-root those relative paths and 404 - // every asset, leaving the panel unstyled and unable to connect. Serving the - // base path verbatim keeps the SPA's relative asset resolution intact. - skipTrailingSlashRedirect: true, -} +}) export default nextConfig diff --git a/packages/next/package.json b/packages/next/package.json new file mode 100644 index 00000000..ec43294b --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,57 @@ +{ + "name": "@devframes/next", + "type": "module", + "version": "0.7.14", + "private": true, + "description": "Next.js host integration for Devframe — serve mounted devframe SPAs and connection meta from an App Router route (SPIKE / proposed, plan 027)", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/next", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "next", + "nextjs", + "devtools" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "prepack": "pnpm run build" + }, + "peerDependencies": { + "devframe": "workspace:*", + "next": "^14.0.0 || ^15.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + } + }, + "dependencies": { + "h3": "catalog:deps" + }, + "devDependencies": { + "@types/node": "catalog:types", + "devframe": "workspace:*", + "tsdown": "catalog:build" + } +} diff --git a/packages/next/src/config.ts b/packages/next/src/config.ts new file mode 100644 index 00000000..9ebe9b16 --- /dev/null +++ b/packages/next/src/config.ts @@ -0,0 +1,36 @@ +/** + * Minimal shape of the bits of `next.config` this helper reads/writes, so the + * package doesn't pull Next's full config type graph into its `.d.ts` bundle. + * Any real `NextConfig` structurally satisfies it. + */ +export interface DevframeNextConfig { + skipTrailingSlashRedirect?: boolean + [key: string]: unknown +} + +/** + * Apply the Next config settings a devframe **host** requires, preserving + * everything else. + * + * Sets `skipTrailingSlashRedirect: true`: mounted devframe SPAs are served at + * `/__/` and reference their assets relatively (`./_next/…`). Next's + * default trailing-slash redirect (`/__git/` → `/__git`) would re-root those + * relative paths and 404 every asset, leaving the panel unstyled and unable to + * connect. Serving the base path verbatim keeps relative resolution intact. + * + * ```js [next.config.mjs] + * import { withDevframe } from '@devframes/next' + * + * export default withDevframe({ + * transpilePackages: ['@antfu/design'], + * }) + * ``` + */ +export function withDevframe( + nextConfig: T = {} as T, +): T { + return { + ...nextConfig, + skipTrailingSlashRedirect: nextConfig.skipTrailingSlashRedirect ?? true, + } +} diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts new file mode 100644 index 00000000..917ef5bc --- /dev/null +++ b/packages/next/src/host.ts @@ -0,0 +1,124 @@ +import type { ConnectionMeta, DevframeHost, DevframeStorageScope } from 'devframe/types' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { serveStaticHandler } from 'devframe/utils/serve-static' +import { H3 } from 'h3' + +export interface CreateDevframeNextHostOptions { + /** + * Public origin the Next app is reachable at, e.g. `http://localhost:3000`. + * Surfaced through {@link DevframeHost.resolveOrigin} for docks that need an + * absolute iframe URL. + */ + resolveOrigin: () => string + /** + * Resolve a directory the host owns for persisted devframe state, per + * {@link DevframeHost.getStorageDir}. + */ + getStorageDir: (scope: DevframeStorageScope) => string + /** + * Initial connection meta served at every base registered via + * {@link DevframeHost.mountConnectionMeta}. Usually unknown until the + * side-car RPC/WS server has started — publish it later with + * {@link DevframeNextHost.setConnectionMeta}. + */ + connectionMeta?: ConnectionMeta +} + +export interface DevframeNextHost { + /** + * The {@link DevframeHost} to hand to `createHubContext` / `createHostContext`. + * Its `mountStatic` / `mountConnectionMeta` calls accumulate into the + * {@link DevframeNextHost.fetch} handler below. + */ + host: DevframeHost + /** + * A WHATWG-`fetch` handler that serves every mounted SPA (with SPA + * fallback, correct content types, and path-traversal guarding — all from + * devframe's own `serveStaticHandler`) and answers `/__connection.json` + * for each base registered via `mountConnectionMeta`. Delegate a Next App + * Router route handler straight to it: + * + * ```ts + * export async function GET(request: Request) { + * return (await ensureHub()).fetch(request) + * } + * ``` + */ + fetch: (request: Request) => Promise + /** + * Publish the live connection meta once the RPC/WS server is up. Until this + * is called (and without an initial `connectionMeta`), meta requests answer + * `503` so a racing client retries rather than caching a wrong endpoint. + */ + setConnectionMeta: (meta: ConnectionMeta) => void +} + +const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` + +/** Drop trailing slashes from a mount base (`/__git/` → `/__git`). */ +function stripTrailingSlash(base: string): string { + return base.replace(/\/+$/, '') +} + +/** + * Build a Node-runtime {@link DevframeHost} for a Next.js App Router app that + * hosts one or more devframes, plus the single `fetch` handler its catch-all + * route delegates to. + * + * This is the hosted-adapter counterpart to `viteDevBridge` for the Next + * runtime, which — being webpack/Turbopack rather than Vite — can't reuse the + * Vite middleware path. Instead of hand-rolling static serving in a route + * handler, static mounts are registered on an internal h3 app and served + * through devframe's shared `serveStaticHandler` (`app.fetch` makes h3 a + * WHATWG-`fetch` handler, exactly what an App Router route returns). + * + * Pins Node runtime (`export const runtime = 'nodejs'` in the route) because + * the static handler streams from the filesystem. + */ +export function createDevframeNextHost( + options: CreateDevframeNextHostOptions, +): DevframeNextHost { + const app = new H3() + const metaBases = new Set() + let connectionMeta = options.connectionMeta + + const host: DevframeHost = { + mountStatic(base, distDir) { + // h3's sub-app mount matches on segment boundaries and strips `base` + // from the path, so the static handler sees paths relative to `distDir` + // — the same longest-prefix behavior the hand-rolled registry did. + const staticApp = new H3() + staticApp.use(serveStaticHandler(distDir)) + app.mount(stripTrailingSlash(base), staticApp) + }, + mountConnectionMeta(base) { + metaBases.add(stripTrailingSlash(base)) + }, + resolveOrigin: options.resolveOrigin, + getStorageDir: options.getStorageDir, + } + + async function fetch(request: Request): Promise { + const { pathname } = new URL(request.url) + + // Answer `/__connection.json` before the static handler runs — a + // mounted SPA's SPA-fallback would otherwise resolve the miss to + // `index.html` and swallow the discovery request. + if (pathname.endsWith(META_SUFFIX) + && metaBases.has(pathname.slice(0, -META_SUFFIX.length))) { + if (!connectionMeta) + return new Response(null, { status: 503 }) + return Response.json(connectionMeta) + } + + return app.fetch(request) + } + + return { + host, + fetch, + setConnectionMeta(meta) { + connectionMeta = meta + }, + } +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts new file mode 100644 index 00000000..54c35048 --- /dev/null +++ b/packages/next/src/index.ts @@ -0,0 +1,8 @@ +export type { DevframeNextConfig } from './config' +export { withDevframe } from './config' + +export type { + CreateDevframeNextHostOptions, + DevframeNextHost, +} from './host' +export { createDevframeNextHost } from './host' diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json new file mode 100644 index 00000000..6646b7b4 --- /dev/null +++ b/packages/next/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/next/tsdown.config.ts b/packages/next/tsdown.config.ts new file mode 100644 index 00000000..534d0843 --- /dev/null +++ b/packages/next/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: './src/index.ts', + platform: 'node', + tsconfig: '../../tsconfig.base.json', + clean: true, + dts: true, + outExtensions: () => ({ dts: '.d.mts' }), +}) diff --git a/plans/README.md b/plans/README.md index ae221b0c..74ed4920 100644 --- a/plans/README.md +++ b/plans/README.md @@ -39,7 +39,7 @@ changes are allowed as long as they're marked). | 024 | Batch stream-replay into one frame | perf | P3 | M | 009 | TODO | | 025 | Cache falsy RPC results (presence check) | bug | P3 | S | — | DONE | | 026 | Clear dev/docs-only dependency advisories | deps | P3 | S | 002 | TODO | -| 027 | Spike: `@devframes/next` host-integration package | direction | P3 | M | — | TODO | +| 027 | Spike: `@devframes/next` host-integration package | direction | P3 | M | — | DONE (spike — proposal `plans/notes/devframes-next-proposal.md`; prototype `packages/next` (private), hub example refactored onto it) | | 029 | Bring `@devframes/plugin-git` to the host baseline | direction/dx | P3 | S-M | — | TODO | | 030 | Spike: server-side auth enforcement ⚠️ | security | P2 | L | 003, 007, 015 | DONE | diff --git a/plans/notes/devframes-next-proposal.md b/plans/notes/devframes-next-proposal.md new file mode 100644 index 00000000..3822cdd7 --- /dev/null +++ b/plans/notes/devframes-next-proposal.md @@ -0,0 +1,158 @@ +# Proposal: `@devframes/next` host-integration package + +> Spike output for plan 027. Status: **prototype landed** as a private package +> (`packages/next`) and proven by refactoring `examples/minimal-next-devframe-hub` +> onto it. This document is the design write-up + open questions a follow-up plan +> uses to promote it to a published package. + +## Problem + +Both Next examples hand-rolled static serving that re-implements what +`packages/devframe/src/utils/serve-static.ts` already owns and tests. The worst +offender was `examples/minimal-next-devframe-hub`, whose App Router catch-all +route (`app/__[id]/[[...path]]/route.ts`, 109 lines) carried its own +content-type map, path-traversal guard, directory→`index.html` resolution, SPA +fallback, and file streaming — plus a `STATIC_MOUNTS` registry, a +`CONNECTION_META_BASES` matcher, and a by-hand `DevframeHost` in its host module +(~90 more lines). `@devframes/nuxt` existed with no Next counterpart, an +asymmetry versus the documented Axis-A targets. + +`@devframes/nuxt` stays tiny (~190 lines) because Nuxt is Vite-based, so it +reuses devframe's `viteDevBridge` verbatim. **Next is webpack/Turbopack, so it +cannot reuse that path** — it must own route handlers. That is the whole reason +the boilerplate existed and the reason a dedicated bridge is justified. + +## What a Next host actually needs + +The example is a **hub** (many devframes mounted at once), not a single plugin. +Its `DevframeHost.mountStatic` / `mountConnectionMeta` are called once per +mounted devframe (git, terminals, inspect, a11y, …) plus the a11y agent module, +accumulating a set of `(base → distDir)` static mounts and a set of +connection-meta bases. So the bridge's core job is not "serve one definition's +`distDir`" (plan 027's first-draft `createDevframeNextHandler(definition)` +signature) — it is: + +> provide a `DevframeHost` whose mount calls accumulate into **one WHATWG-`fetch` +> handler** that a Next catch-all route delegates to. + +## API (implemented) + +```ts +import { createDevframeNextHost, withDevframe } from '@devframes/next' + +const { host, fetch, setConnectionMeta } = createDevframeNextHost({ + resolveOrigin: () => 'http://localhost:3000', + getStorageDir: scope => /* workspace | project | global dir */, +}) +``` + +- **`createDevframeNextHost(options) → { host, fetch, setConnectionMeta }`** + - `host: DevframeHost` — hand to `createHubContext` / `createHostContext`. + `mountStatic(base, dir)` mounts `dir` on an internal h3 app at `base`; + `mountConnectionMeta(base)` registers a meta base. + - `fetch(request: Request) => Promise` — the single handler both + App Router routes delegate to. Serves every mounted SPA through devframe's + shared `serveStaticHandler` (SPA fallback, content types, traversal guard + all reused) and answers `/__connection.json` for each registered base + **before** the static handler runs, so an SPA fallback can't swallow the + discovery fetch. + - `setConnectionMeta(meta)` — publish the live meta once the RPC/WS port is + known. Until then meta requests return `503` (a racing client retries rather + than caching a wrong endpoint). +- **`withDevframe(nextConfig) → nextConfig`** — applies the host-mode Next + setting `skipTrailingSlashRedirect: true` (so mounted SPAs' relative assets + under `/__/` resolve rather than 404 on Next's trailing-slash redirect), + preserving everything else. + +### How the reuse works + +`serveStaticHandler` is an h3 v2 `EventHandler` and h3 v2's `H3` instance is +itself a WHATWG-`fetch` handler (`app.fetch(request)`), which is exactly what an +App Router route returns. So the bridge mounts one static sub-app per base +(`app.mount(base, sub)` — h3 strips the base and matches on segment boundaries, +giving the same longest-prefix behavior the hand-rolled registry did) and +delegates. No static-serving logic is re-implemented; the package is ~130 lines. + +### Route handler after the refactor + +```ts +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request): Promise { + const hub = await ensureMinimalNextDevframeHub() + return hub.fetch(request) +} +``` + +Both the catch-all SPA route and the `__hub/__connection.json` route collapse to +this (the host registers `/__hub` as a meta base so the one `fetch` covers the +hub's own discovery too). + +## Runtime choice: Node + +The route pins `export const runtime = 'nodejs'` because `serveStaticHandler` +streams from the filesystem (`node:fs` + `node:stream`). Edge is out of scope: +serving a built SPA off disk is inherently a Node concern, and the side-car +RPC/WS server the hub starts is Node-only regardless. An Edge story would need a +fetch-native asset source (e.g. imported/bundled assets) and is not required by +either example — noted as a non-goal, not a limitation to design around now. + +## Connection-descriptor contract + +Consistent across both Next examples and every other adapter: the live shape is +`{ backend: 'websocket', websocket: }` and the baked +(static-build) shape is `{ backend: 'static' }`. The bridge serves whatever meta +`setConnectionMeta` is handed, so it imposes no new contract. Plan 027's second +STOP condition (contract divergence) does not trip. + +## Acceptance (verified in the spike) + +- `pnpm --filter @devframes/next build` + `typecheck` — green; emits + `dist/index.mjs` + `dist/index.d.mts`. +- `pnpm typecheck` (all 21 workspace tasks, incl. the coverage guard) — green. +- `examples/minimal-next-devframe-hub` refactored onto the bridge: the 109-line + catch-all route, both hand-rolled registries, and the by-hand `DevframeHost` + are gone; both routes now delegate to `hub.fetch`. `pnpm --filter + minimal-next-devframe-hub build` (Next 16 / Turbopack) succeeds and the + example's 4 existing tests pass. +- Runtime smoke against a **real** built plugin SPA (`plugins/git/dist/client`) + confirmed: dir→`index.html`, direct file with correct content type, SPA + fallback for extensionless routes, `HEAD`, `404` for unmounted bases, meta + `503`→`200` after `setConnectionMeta`, and meta taking precedence over SPA + fallback for both the plugin and hub bases. + +## Open questions (for the promotion plan) + +1. **`createDevframeNextHandler(definition)` convenience wrapper.** The + single-plugin case (mount one `def.cli.distDir` + run a side-car dev server, + mirroring `viteDevBridge`'s two modes) is not implemented — there is no + single-plugin Next example to test it against, so building it now would be + untested code. Add it (and an example) when `plugins/git` grows a `/next` + entry (the sibling of plan 029's `/vite` work). +2. **Client surface.** Nuxt ships a client plugin exposing `$rpc`. The Next + equivalent — a shipped `RpcProvider` / `useRpc` React context wrapping + `connectDevframe()`, and a `` / layout helper — is + deferred; the hub example still hand-writes its provider in `page.tsx`. Decide + whether these belong in `@devframes/next` (React peer) or stay app-owned. +3. **Publishing shape.** Currently `private: true`. To publish: drop `private`, + which pulls it into the `tsnapi` API snapshot (`tests/exports.test.ts` filters + out private packages) — a snapshot will need to be generated and reviewed. + Confirm `next` as an optional peer (already declared) and the export map + mirrors the Axis-A baseline. +4. **404 body.** h3's default handler returns a small JSON error body for an + unmounted path; the old route returned an empty 404. Harmless for asset loads, + but decide whether to install a bare-404 error handler for parity. +5. **`next-runtime-snapshot` needs no bridge.** It uses Next only as a static-SPA + builder while devframe owns the server via `createCac`/`createBuild`; the + bridge doesn't apply. Leave it as-is (documented here so a future reader + doesn't try to "unify" the two Next examples). + +## Files + +- `packages/next/` — `src/host.ts` (`createDevframeNextHost`), `src/config.ts` + (`withDevframe`), `src/index.ts`, plus `package.json` / `tsconfig.json` / + `tsdown.config.ts` mirroring `packages/nuxt`. +- `tsconfig.base.json` — `@devframes/next` source alias. +- `examples/minimal-next-devframe-hub/` — host module, both route handlers, and + `next.config.mjs` refactored onto the bridge; `@devframes/next` added as a dep. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a72ad63..5a430d41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,9 @@ importers: '@devframes/json-render': specifier: workspace:* version: link:../../packages/json-render + '@devframes/next': + specifier: workspace:* + version: link:../../packages/next '@devframes/plugin-a11y': specifier: workspace:* version: link:../../plugins/a11y @@ -1004,6 +1007,25 @@ importers: specifier: catalog:frontend version: 3.5.40(typescript@5.9.3) + packages/next: + dependencies: + h3: + specifier: catalog:deps + version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) + next: + specifier: ^14.0.0 || ^15.0.0 + version: 15.5.22(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@types/node': + specifier: catalog:types + version: 26.1.1 + devframe: + specifier: workspace:* + version: link:../devframe + tsdown: + specifier: catalog:build + version: 0.22.14(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + packages/nuxt: devDependencies: '@nuxt/kit': @@ -2548,21 +2570,43 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/env@15.5.22': + resolution: {integrity: sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==} + '@next/env@16.2.11': resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==} + '@next/swc-darwin-arm64@15.5.22': + resolution: {integrity: sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-arm64@16.2.11': resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-x64@15.5.22': + resolution: {integrity: sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-darwin-x64@16.2.11': resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-linux-arm64-gnu@15.5.22': + resolution: {integrity: sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-gnu@16.2.11': resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==} engines: {node: '>= 10'} @@ -2570,6 +2614,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-musl@15.5.22': + resolution: {integrity: sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-arm64-musl@16.2.11': resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==} engines: {node: '>= 10'} @@ -2577,6 +2628,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-gnu@15.5.22': + resolution: {integrity: sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-gnu@16.2.11': resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==} engines: {node: '>= 10'} @@ -2584,6 +2642,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-musl@15.5.22': + resolution: {integrity: sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-musl@16.2.11': resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==} engines: {node: '>= 10'} @@ -2591,12 +2656,24 @@ packages: os: [linux] libc: [musl] + '@next/swc-win32-arm64-msvc@15.5.22': + resolution: {integrity: sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-arm64-msvc@16.2.11': resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-x64-msvc@15.5.22': + resolution: {integrity: sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@next/swc-win32-x64-msvc@16.2.11': resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==} engines: {node: '>= 10'} @@ -7658,6 +7735,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@15.5.22: + resolution: {integrity: sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + next@16.2.11: resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==} engines: {node: '>=20.9.0'} @@ -10882,29 +10980,55 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@next/env@15.5.22': {} + '@next/env@16.2.11': {} + '@next/swc-darwin-arm64@15.5.22': + optional: true + '@next/swc-darwin-arm64@16.2.11': optional: true + '@next/swc-darwin-x64@15.5.22': + optional: true + '@next/swc-darwin-x64@16.2.11': optional: true + '@next/swc-linux-arm64-gnu@15.5.22': + optional: true + '@next/swc-linux-arm64-gnu@16.2.11': optional: true + '@next/swc-linux-arm64-musl@15.5.22': + optional: true + '@next/swc-linux-arm64-musl@16.2.11': optional: true + '@next/swc-linux-x64-gnu@15.5.22': + optional: true + '@next/swc-linux-x64-gnu@16.2.11': optional: true + '@next/swc-linux-x64-musl@15.5.22': + optional: true + '@next/swc-linux-x64-musl@16.2.11': optional: true + '@next/swc-win32-arm64-msvc@15.5.22': + optional: true + '@next/swc-win32-arm64-msvc@16.2.11': optional: true + '@next/swc-win32-x64-msvc@15.5.22': + optional: true + '@next/swc-win32-x64-msvc@16.2.11': optional: true @@ -16156,6 +16280,30 @@ snapshots: negotiator@1.0.0: {} + next@15.5.22(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 15.5.22 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001806 + postcss: 8.4.31 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@10.2.2))(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.22 + '@next/swc-darwin-x64': 15.5.22 + '@next/swc-linux-arm64-gnu': 15.5.22 + '@next/swc-linux-arm64-musl': 15.5.22 + '@next/swc-linux-x64-gnu': 15.5.22 + '@next/swc-linux-x64-musl': 15.5.22 + '@next/swc-win32-arm64-msvc': 15.5.22 + '@next/swc-win32-x64-msvc': 15.5.22 + '@playwright/test': 1.61.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@16.2.11(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.11 diff --git a/turbo.json b/turbo.json index bfcc0fd8..ca0b6e39 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,10 @@ "outputLogs": "new-only", "outputs": ["dist/**"] }, + "@devframes/next#build": { + "outputLogs": "new-only", + "outputs": ["dist/**"] + }, "@devframes/json-render#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build", "@devframes/hub#build"], @@ -79,6 +83,7 @@ "dependsOn": [ "@devframes/hub#build", "devframe#build", + "@devframes/next#build", "@devframes/plugin-git#build", "@devframes/plugin-terminals#build", "@devframes/plugin-code-server#build", From fd58255a5b6f28d3d3c645aafa1d3a0a6216d4d9 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 05:51:47 +0000 Subject: [PATCH 2/5] docs(next): document the @devframes/next helper; drop completed plan 027 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/helpers/next.md (sidebar + helpers index + example cross-links), flagged experimental. Remove the plan-027 spec file now that it's DONE — the plans/README.md row remains the record, pointing at the proposal notes. --- docs/.vitepress/config.ts | 1 + docs/examples/minimal-next-devframe-hub.md | 6 +- docs/helpers/index.md | 1 + docs/helpers/next.md | 92 +++++++++++++++++++ plans/027-spike-devframes-next.md | 102 --------------------- 5 files changed, 99 insertions(+), 103 deletions(-) create mode 100644 docs/helpers/next.md delete mode 100644 plans/027-spike-devframes-next.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0c63445a..7b905336 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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[] diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index 10b4cb1b..de617263 100644 --- a/docs/examples/minimal-next-devframe-hub.md +++ b/docs/examples/minimal-next-devframe-hub.md @@ -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. @@ -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) diff --git a/docs/helpers/index.md b/docs/helpers/index.md index 237090e8..1cb096cf 100644 --- a/docs/helpers/index.md +++ b/docs/helpers/index.md @@ -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 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. | diff --git a/docs/helpers/next.md b/docs/helpers/next.md new file mode 100644 index 00000000..a9483aa5 --- /dev/null +++ b/docs/helpers/next.md @@ -0,0 +1,92 @@ +--- +outline: deep +--- + +# Next Helper + +> [!WARNING] +> Experimental. `@devframes/next` ships as an in-repo spike ([plan 027](https://github.com/devframes/devframe/blob/main/plans/notes/devframes-next-proposal.md)) and isn't published to npm yet. The API described here is the proposed surface; expect changes before a stable release. + +The `@devframes/next` package hosts one or more 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 hands you a `DevframeHost` plus a single `fetch` handler your catch-all route delegates to. + +It handles the two things a Next.js host needs: + +1. **Static + connection serving.** `createDevframeNextHost()` returns a `DevframeHost` whose `mountStatic` / `mountConnectionMeta` calls accumulate into one WHATWG-`fetch` handler that serves every mounted SPA — reusing devframe's own [`serveStaticHandler`](/adapters/dev) for SPA fallback, content types, and path-traversal guarding — and answers each `/__connection.json`. +2. **Host-mode Next config.** `withDevframe()` applies the one Next setting a devframe host requires. + +## Install + +```ts [next.config.mjs] +import { withDevframe } from '@devframes/next' + +export default withDevframe({ + transpilePackages: ['@antfu/design'], +}) +``` + +`withDevframe` sets `skipTrailingSlashRedirect: true` and preserves the rest of your config. Mounted SPAs are served at `/__/` 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 devframe + +Build the host once (a module-level singleton, since App Router invokes route handlers per request), then delegate both routes to its `fetch`: + +```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 { + return hub.fetch(request) // serves every mounted SPA + connection meta +} +``` + +The same `fetch` answers the hub's own `__hub/__connection.json` — register `/__hub` with `mountConnectionMeta` and one route body covers both. + +## API + +### `createDevframeNextHost(options)` + +| Option | Description | +|--------|-------------| +| `resolveOrigin` | Returns the public origin the app is reachable at, for docks needing an absolute iframe URL. | +| `getStorageDir` | Resolves a directory for persisted state per `scope` (`workspace` / `project` / `global`). | +| `connectionMeta` | Optional initial meta; usually published later via `setConnectionMeta`. | + +Returns `{ host, fetch, setConnectionMeta }`: + +- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`. +- **`fetch(request)`** — the WHATWG-`fetch` handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a `/__connection.json` discovery fetch. +- **`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. + +### `withDevframe(nextConfig)` + +Returns a Next config with `skipTrailingSlashRedirect: true` applied, preserving everything else. + +## 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 the hub starts 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 diff --git a/plans/027-spike-devframes-next.md b/plans/027-spike-devframes-next.md deleted file mode 100644 index 8c3bf582..00000000 --- a/plans/027-spike-devframes-next.md +++ /dev/null @@ -1,102 +0,0 @@ -# Plan 027 (spike): Design a `@devframes/next` host-integration package - -> **Executor instructions**: This is a **design/spike** plan — investigate, -> prototype minimally, and produce a written proposal + a thin working slice. Do -> NOT build the full package to production polish. If a STOP condition occurs, -> stop and report. When done, update this plan's row in `plans/README.md`. -> -> **Drift check (run first)**: `git diff --stat 610a7b0..HEAD -- packages/nuxt examples/minimal-next-devframe-hub examples/next-runtime-snapshot packages/devframe/src/utils/serve-static.ts` -> On a mismatch with "Current state", STOP. - -## Status - -- **Priority**: P3 -- **Effort**: M (spike) -- **Risk**: LOW-MED -- **Depends on**: none -- **Category**: direction -- **Planned at**: commit `610a7b0`, 2026-07-06 - -## Why this matters - -Both Next examples hand-roll static serving that re-implements what devframe -already owns and tests — `examples/minimal-next-devframe-hub/src/client/app/__[id]/[[...path]]/route.ts` -is a ~109-line catch-all with its own content-type map, path-traversal guard, and -SPA fallback, duplicating `packages/devframe/src/utils/serve-static.ts` -(`serveStaticHandler`/`mountStaticHandler`, tested in `serve-static.test.ts`). -`@devframes/nuxt` exists but there is **no `/next` counterpart** — a surface -asymmetry, despite `todos/README.md` listing `/next` as a first-class Axis-A -target. A thin package removes fragile boilerplate for a stated audience and -closes the Nuxt/Next parity gap. - -## Current state - -- `packages/nuxt/` is the model: a real `@devframes/nuxt` module (`src/module.ts` - + `src/runtime/`), peer-depends on `devframe`, framework as an optional peer. -- `packages/devframe/src/utils/serve-static.ts` exports `serveStaticHandler`, - `mountStaticHandler`, and `serveStaticNodeMiddleware` — the reusable core. -- The two Next examples wire devframe by hand (`examples/next-runtime-snapshot/src/devframe.ts`; - the minimal-next route file above). -- `todos/README.md:43-52` documents the Axis-A targets (`.`/`/cli`/`/vite` baseline - + opt-in `/nuxt`,`/next`,…). - -## Scope - -**In scope** (spike deliverables): -- A written proposal: `plans/notes/devframes-next-proposal.md` (or inline in the - PR description) covering the API surface, Next runtime choice (Node vs Edge), - and how the connection descriptor (`__connection.json`) is served. -- A thin prototype package `packages/next/` OR a proof in one example: a Next - route-handler factory that serves the SPA via `serveStaticHandler` and responds - with connection meta, replacing the hand-rolled route in **one** example as the - acceptance test. - -**Out of scope**: shipping/publishing the package, full docs, `/rspack`/`/webpack`. -Keep it a spike behind a clear "proposed" label. - -## Steps - -1. **Study the model**: read `packages/nuxt/` (module + runtime), the hand-rolled - Next route(s), and `serve-static.ts`'s exports. Note what the Next route needs - that Nuxt's module provides. -2. **Draft the API**: a `createDevframeNextHandler(definition, options)` (or - similar) returning a catch-all `GET` handler + a `__connection.json` responder, - built on `serveStaticHandler`/`serveStaticNodeMiddleware`. Decide Node vs Edge - runtime and document the tradeoff. -3. **Prototype**: implement the thin handler (in `packages/next/` or inline) and - refactor **one** Next example onto it. Confirm the example still runs - (`pnpm --filter build` + a manual/e2e check) and serves the SPA + - connection meta identically. -4. **Write up open questions**: package naming/exports (mirror the Axis-A - baseline), peer-dep shape (Next as optional peer), and whether the runtime - snapshot example also migrates. - -## Commands you will need - -| Purpose | Command | Expected | -|---|---|---| -| Build an example | `pnpm --filter minimal-next-devframe-hub build` | exit 0 | -| Typecheck | `pnpm typecheck` | exit 0 (if a new package is added, it needs a `typecheck` script — see plan 001) | - -## Done criteria - -- [ ] A written proposal (API, runtime choice, connection-meta serving, open questions) exists. -- [ ] A thin prototype serves an example's SPA + `__connection.json` via `serveStaticHandler` (no hand-rolled static logic in that example's path). -- [ ] The migrated example builds and serves identically (manual/e2e check noted). -- [ ] If a new `packages/next/` is added, it has a `typecheck` script and passes `pnpm typecheck`. -- [ ] `plans/README.md` status row updated. - -## STOP conditions - -Stop and report if: -- The Next App-Router runtime makes reusing `serveStaticHandler` (h3/Node stream - based) impractical on the target runtime — report the constraint and propose - the `serveStaticNodeMiddleware` or a fetch-based adaptation instead. -- The connection-descriptor contract differs between the two Next examples — - reconcile in the proposal before generalizing. - -## Maintenance notes - -- Ship as a spike/proposal; a follow-up plan promotes it to a published package - with docs once the API is agreed. -- Reviewer: judge the proposal's API against `@devframes/nuxt` for consistency. From 9bbb5828c9384984e7f831f6535054cd6158d324 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 06:34:14 +0000 Subject: [PATCH 3/5] feat(next): promote @devframes/next to a published experimental package - Publish shape: drop private, publishConfig experimental tag, react/next optional peers, add ./client export; generate tsnapi API snapshots. - Add createDevframeNextHandler: single-plugin host wrapper that serves the SPA and starts a bridge-mode side-car RPC/WS server (mirrors viteDevBridge). - Add @devframes/next/client: React RpcProvider + useRpc surface. - Normalize the host fetch miss to a bare 404 (static-server parity). - Register source aliases + a vitest project; cover the handler with a test. - Docs: document both host modes + the client surface; drop the design-system transpilePackages example. Update the proposal notes to the promoted state. --- alias.ts | 2 + docs/helpers/index.md | 2 +- docs/helpers/next.md | 87 +++++++---- packages/next/package.json | 19 ++- packages/next/src/client.tsx | 86 +++++++++++ packages/next/src/config.ts | 2 +- packages/next/src/handler.ts | 139 ++++++++++++++++++ packages/next/src/host.ts | 8 +- packages/next/src/index.ts | 6 + packages/next/test/handler.test.ts | 66 +++++++++ packages/next/tsconfig.json | 3 +- packages/next/tsdown.config.ts | 34 ++++- plans/notes/devframes-next-proposal.md | 69 +++++---- pnpm-lock.yaml | 136 +---------------- .../@devframes/next/client.snapshot.d.ts | 16 ++ .../tsnapi/@devframes/next/client.snapshot.js | 7 + .../@devframes/next/index.snapshot.d.ts | 39 +++++ .../tsnapi/@devframes/next/index.snapshot.js | 8 + tsconfig.base.json | 6 + vitest.config.ts | 1 + 20 files changed, 536 insertions(+), 200 deletions(-) create mode 100644 packages/next/src/client.tsx create mode 100644 packages/next/src/handler.ts create mode 100644 packages/next/test/handler.test.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.js diff --git a/alias.ts b/alias.ts index 96f5805c..788d6b25 100644 --- a/alias.ts +++ b/alias.ts @@ -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'), diff --git a/docs/helpers/index.md b/docs/helpers/index.md index 1cb096cf..f5f2c8a2 100644 --- a/docs/helpers/index.md +++ b/docs/helpers/index.md @@ -11,7 +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 for mounting devframes inside a Next.js App Router app (experimental). | +| [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. | diff --git a/docs/helpers/next.md b/docs/helpers/next.md index a9483aa5..0b4c5ebd 100644 --- a/docs/helpers/next.md +++ b/docs/helpers/next.md @@ -5,30 +5,58 @@ outline: deep # Next Helper > [!WARNING] -> Experimental. `@devframes/next` ships as an in-repo spike ([plan 027](https://github.com/devframes/devframe/blob/main/plans/notes/devframes-next-proposal.md)) and isn't published to npm yet. The API described here is the proposed surface; expect changes before a stable release. +> Experimental. `@devframes/next` is published under the `experimental` npm tag (`npm i @devframes/next@experimental`) while its API settles. Expect changes before a stable release. -The `@devframes/next` package hosts one or more 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 hands you a `DevframeHost` plus a single `fetch` handler your catch-all route delegates to. +`@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 handles the two things a Next.js host needs: +It comes in three parts: -1. **Static + connection serving.** `createDevframeNextHost()` returns a `DevframeHost` whose `mountStatic` / `mountConnectionMeta` calls accumulate into one WHATWG-`fetch` handler that serves every mounted SPA — reusing devframe's own [`serveStaticHandler`](/adapters/dev) for SPA fallback, content types, and path-traversal guarding — and answers each `/__connection.json`. -2. **Host-mode Next config.** `withDevframe()` applies the one Next setting a devframe host requires. +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. -## Install +Plus a React client surface at `@devframes/next/client`. + +## Config ```ts [next.config.mjs] import { withDevframe } from '@devframes/next' export default withDevframe({ - transpilePackages: ['@antfu/design'], + // ...your own Next config }) ``` -`withDevframe` sets `skipTrailingSlashRedirect: true` and preserves the rest of your config. Mounted SPAs are served at `/__/` 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. +`withDevframe` sets `skipTrailingSlashRedirect: true` and preserves the rest. Mounted SPAs are served at `/__/` 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 `/__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 ?? '/__/'`. `close()` shuts the side-car down; `ready` resolves once it's listening. -## Hosting a devframe +| Option | Default | Description | +|--------|---------|-------------| +| `base` | `def.basePath ?? '/__/'` | 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. | -Build the host once (a module-level singleton, since App Router invokes route handlers per request), then delegate both routes to its `fetch`: +## 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' @@ -59,31 +87,40 @@ export async function GET(request: Request): Promise { } ``` -The same `fetch` answers the hub's own `__hub/__connection.json` — register `/__hub` with `mountConnectionMeta` and one route body covers both. +`createDevframeNextHost` returns `{ host, fetch, setConnectionMeta }`: -## API +- **`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 `/__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. -### `createDevframeNextHost(options)` +## React client -| Option | Description | -|--------|-------------| -| `resolveOrigin` | Returns the public origin the app is reachable at, for docks needing an absolute iframe URL. | -| `getStorageDir` | Resolves a directory for persisted state per `scope` (`workspace` / `project` / `global`). | -| `connectionMeta` | Optional initial meta; usually published later via `setConnectionMeta`. | +`@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. -Returns `{ host, fetch, setConnectionMeta }`: +```tsx [app/providers.tsx] +'use client' +import { RpcProvider } from '@devframes/next/client' -- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`. -- **`fetch(request)`** — the WHATWG-`fetch` handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a `/__connection.json` discovery fetch. -- **`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. +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} +``` + +```tsx [app/panel.tsx] +'use client' +import { useRpc } from '@devframes/next/client' -### `withDevframe(nextConfig)` +export function Panel() { + const rpc = useRpc().scope('my-tool:') + // rpc.call('get-payload'), rpc.sharedState, … +} +``` -Returns a Next config with `skipTrailingSlashRedirect: true` applied, preserving everything else. +`RpcProvider` renders its `fallback` (default `null`) until the client connects, so `useRpc()` always returns a live client. 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 the hub starts is a Node process. +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 diff --git a/packages/next/package.json b/packages/next/package.json index ec43294b..333835c4 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -2,8 +2,7 @@ "name": "@devframes/next", "type": "module", "version": "0.7.14", - "private": true, - "description": "Next.js host integration for Devframe — serve mounted devframe SPAs and connection meta from an App Router route (SPIKE / proposed, plan 027)", + "description": "Next.js host integration for Devframe — serve mounted devframe SPAs and connection meta from an App Router route (experimental)", "author": "Anthony Fu ", "license": "MIT", "homepage": "https://github.com/devframes/devframe#readme", @@ -25,12 +24,20 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, + "./client": { + "types": "./dist/client.d.mts", + "default": "./dist/client.mjs" + }, "./package.json": "./package.json" }, "types": "./dist/index.d.mts", "files": [ "dist" ], + "publishConfig": { + "access": "public", + "tag": "experimental" + }, "scripts": { "build": "tsdown", "watch": "tsdown --watch", @@ -39,11 +46,15 @@ }, "peerDependencies": { "devframe": "workspace:*", - "next": "^14.0.0 || ^15.0.0" + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "next": { "optional": true + }, + "react": { + "optional": true } }, "dependencies": { @@ -51,7 +62,9 @@ }, "devDependencies": { "@types/node": "catalog:types", + "@types/react": "catalog:types", "devframe": "workspace:*", + "react": "catalog:frontend", "tsdown": "catalog:build" } } diff --git a/packages/next/src/client.tsx b/packages/next/src/client.tsx new file mode 100644 index 00000000..7f9223dc --- /dev/null +++ b/packages/next/src/client.tsx @@ -0,0 +1,86 @@ +'use client' + +import type { DevframeRpcClient } from 'devframe/client' +import type { ConnectionMeta } from 'devframe/types' +import type { ReactNode } from 'react' +import { connectDevframe } from 'devframe/client' +import { createContext, useContext, useEffect, useState } from 'react' + +const RpcContext = createContext(null) + +export interface RpcProviderProps { + children: ReactNode + /** + * Base URL the SPA discovers `__connection.json` under, relative to the + * running page. Defaults to `'./'` (resolved against `document.baseURI`), so + * a devframe mounted at `/__/` connects without extra configuration. + */ + baseURL?: string + /** + * Pre-resolved connection meta. Pass this to skip the `__connection.json` + * fetch entirely (e.g. when the host already knows the WS endpoint). + */ + connectionMeta?: ConnectionMeta + /** + * Rendered while the client is connecting. Children mount only once the RPC + * client is ready, so {@link useRpc} always returns a live client. Defaults + * to `null`. + */ + fallback?: ReactNode +} + +/** + * Connect to the devframe RPC backend once and provide the client to the tree. + * + * The React counterpart to `@devframes/nuxt`'s client plugin: it calls + * `connectDevframe()` on mount and exposes the result through {@link useRpc}. + * Being a client component, drop it into a Next layout or page and read the + * client from any descendant. + * + * ```tsx + * 'use client' + * import { RpcProvider } from '@devframes/next/client' + * + * export function Providers({ children }: { children: React.ReactNode }) { + * return {children} + * } + * ``` + */ +export function RpcProvider({ + children, + baseURL = './', + connectionMeta, + fallback = null, +}: RpcProviderProps): ReactNode { + const [rpc, setRpc] = useState(null) + + useEffect(() => { + let active = true + void connectDevframe({ baseURL, connectionMeta }).then((client) => { + if (active) + setRpc(client) + }) + return () => { + active = false + } + // `connectionMeta` is a plain descriptor; re-connect only when the base changes. + }, [baseURL]) + + if (!rpc) + return fallback + + return {children} +} + +/** + * Read the connected {@link DevframeRpcClient} provided by {@link RpcProvider}. + * Scope it to your tool's RPC namespace with `useRpc().scope('my-tool:…')`. + * + * Throws when called outside a ``. + */ +export function useRpc(): DevframeRpcClient { + const rpc = useContext(RpcContext) + if (!rpc) + throw new Error('[@devframes/next] useRpc() must be called inside a .') + return rpc +} diff --git a/packages/next/src/config.ts b/packages/next/src/config.ts index 9ebe9b16..69d6117b 100644 --- a/packages/next/src/config.ts +++ b/packages/next/src/config.ts @@ -22,7 +22,7 @@ export interface DevframeNextConfig { * import { withDevframe } from '@devframes/next' * * export default withDevframe({ - * transpilePackages: ['@antfu/design'], + * // ...your own Next config * }) * ``` */ diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts new file mode 100644 index 00000000..c264352f --- /dev/null +++ b/packages/next/src/handler.ts @@ -0,0 +1,139 @@ +import type { CreateDevServerOptions } from 'devframe/adapters/dev' +import type { StartedServer } from 'devframe/node' +import type { DevframeDefinition, DevframeStorageScope } from 'devframe/types' +import { homedir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { createDevServer, resolveDevServerPort } from 'devframe/adapters/dev' +import { DEVFRAME_WS_ROUTE } from 'devframe/constants' +import { createDevframeNextHost } from './host' + +export interface CreateDevframeNextHandlerOptions { + /** + * Mount base for the SPA. Defaults to `def.basePath ?? '/__/'` — the + * hosted-adapter default, so the devframe shares the Next app's origin + * without colliding with its routes. + */ + base?: string + /** Bind host for the side-car RPC/WS server. Default: `def.cli?.host ?? 'localhost'`. */ + host?: string + /** Pin the side-car port. Default: resolved from `def.cli?.port` via `get-port-please`. */ + port?: number + /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ + flags?: Record + /** + * Whether the side-car runs its own auth gate. Defaults to `false`: this is a + * hosted adapter and the Next app owns authentication. Pass `true` for + * devframe's interactive gate or a handler for a custom scheme. + */ + auth?: CreateDevServerOptions['auth'] + /** Origin the Next app is reachable at, for docks needing an absolute URL. */ + resolveOrigin?: () => string + /** Override where persisted devframe state lives (defaults under the cwd / home). */ + getStorageDir?: (scope: DevframeStorageScope) => string +} + +export interface DevframeNextHandler { + /** + * WHATWG-`fetch` handler for the catch-all App Router route. Serves the + * plugin's built SPA at `base` and answers `/__connection.json` with + * the side-car WS endpoint. Awaits {@link DevframeNextHandler.ready} so the + * first request doesn't race the server boot. + */ + fetch: (request: Request) => Promise + /** Resolves once the side-car RPC/WS server is listening. */ + ready: Promise + /** Shut the side-car server down (call from an app-lifecycle hook / test). */ + close: () => Promise +} + +/** Ensure a mount base has a single leading and trailing slash. */ +function normalizeBase(base: string): string { + return `/${base}/`.replace(/\/{2,}/g, '/') +} + +function defaultGetStorageDir(scope: DevframeStorageScope): string { + const cwd = process.cwd() + if (scope === 'workspace') + return join(cwd, '.devframe') + if (scope === 'project') + return join(cwd, 'node_modules/.devframe') + return join(homedir(), '.devframe') +} + +/** + * Host a **single** devframe from a Next.js App Router app — the convenience + * wrapper over {@link createDevframeNextHost} for the common case of mounting + * one plugin (the Next counterpart to `viteDevBridge`'s bridge mode). + * + * It statically serves `def.cli.distDir` at `base` through the Next route and + * starts a side-car RPC/WS dev server (via `createDevServer` in bridge mode) on + * its own port, advertising that endpoint at `/__connection.json` so the + * SPA's `connectDevframe()` can dial back in. + * + * ```ts [app/__my-tool/[[...path]]/route.ts] + * import myDevframe from '@/devframe' + * import { createDevframeNextHandler } from '@devframes/next' + * + * export const runtime = 'nodejs' + * export const dynamic = 'force-dynamic' + * + * const handler = createDevframeNextHandler(myDevframe) + * export const GET = handler.fetch + * ``` + * + * For a hub hosting many devframes at once, use {@link createDevframeNextHost} + * directly with `@devframes/hub`. + */ +export function createDevframeNextHandler( + def: DevframeDefinition, + options: CreateDevframeNextHandlerOptions = {}, +): DevframeNextHandler { + const distDir = def.cli?.distDir + if (!distDir) { + throw new Error( + `[@devframes/next] createDevframeNextHandler("${def.id}") needs a built SPA to serve, but "cli.distDir" is not set on the devframe definition.`, + ) + } + + const base = normalizeBase(options.base ?? def.basePath ?? `/__${def.id}/`) + const hostName = options.host ?? def.cli?.host + + const nextHost = createDevframeNextHost({ + resolveOrigin: options.resolveOrigin ?? (() => ''), + getStorageDir: options.getStorageDir ?? defaultGetStorageDir, + }) + nextHost.host.mountStatic(base, distDir) + nextHost.host.mountConnectionMeta?.(base) + + let started: StartedServer | undefined + const ready = (async () => { + const port = options.port ?? await resolveDevServerPort(def, { host: hostName }) + // Bridge mode: no `distDir` passed, so the side-car serves only the WS + // endpoint + meta on its own port; the Next route serves the SPA. The + // side-car mounts the WS at `/` on the standalone base. + started = await createDevServer(def, { + host: hostName, + port, + flags: options.flags, + openBrowser: false, + auth: options.auth ?? false, + }) + nextHost.setConnectionMeta({ + backend: 'websocket', + websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, + }) + })() + + return { + async fetch(request) { + await ready + return nextHost.fetch(request) + }, + ready, + async close() { + await ready.catch(() => {}) + await started?.close() + }, + } +} diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 917ef5bc..4ea0d613 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -111,7 +111,13 @@ export function createDevframeNextHost( return Response.json(connectionMeta) } - return app.fetch(request) + const response = await app.fetch(request) + // Normalize a miss to a bare 404. An unmounted path falls through to h3's + // default handler, which returns a JSON error body; for an asset host a + // body-less 404 is cleaner and matches a plain static server. + if (response.status === 404) + return new Response(null, { status: 404 }) + return response } return { diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 54c35048..3318305e 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,6 +1,12 @@ export type { DevframeNextConfig } from './config' export { withDevframe } from './config' +export type { + CreateDevframeNextHandlerOptions, + DevframeNextHandler, +} from './handler' +export { createDevframeNextHandler } from './handler' + export type { CreateDevframeNextHostOptions, DevframeNextHost, diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts new file mode 100644 index 00000000..27aae05f --- /dev/null +++ b/packages/next/test/handler.test.ts @@ -0,0 +1,66 @@ +import type { DevframeDefinition } from 'devframe/types' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createDevframeNextHandler } from '../src/handler' + +function makeDef(distDir: string): DevframeDefinition { + return { + id: 'test-next', + name: 'Test Next', + version: '0.0.0', + packageName: '@test/next', + homepage: '', + description: '', + cli: { distDir }, + setup() {}, + } +} + +describe('createDevframeNextHandler', () => { + let handler: ReturnType | undefined + + afterEach(async () => { + await handler?.close() + handler = undefined + }) + + it('serves the SPA and advertises the side-car WS endpoint', async () => { + const dist = mkdtempSync(join(tmpdir(), 'df-next-')) + writeFileSync(join(dist, 'index.html'), 'ok') + + handler = createDevframeNextHandler(makeDef(dist), { host: '127.0.0.1' }) + await handler.ready + + const origin = 'http://localhost:3000' + + // Base → index.html. + const index = await handler.fetch(new Request(`${origin}/__test-next/`)) + expect(index.status).toBe(200) + expect(index.headers.get('content-type')).toContain('text/html') + + // SPA fallback for an extensionless in-app route. + const spa = await handler.fetch(new Request(`${origin}/__test-next/some/view`)) + expect(spa.status).toBe(200) + + // Connection meta points at the side-car WS port. + const meta = await handler.fetch(new Request(`${origin}/__test-next/__connection.json`)) + expect(meta.status).toBe(200) + const body = await meta.json() as { backend: string, websocket: { port: number, path: string } } + expect(body.backend).toBe('websocket') + expect(typeof body.websocket.port).toBe('number') + expect(body.websocket.path).toBe('/__devframe_ws') + + // Unmounted base → bare 404. + const miss = await handler.fetch(new Request(`${origin}/__other/x`)) + expect(miss.status).toBe(404) + expect(await miss.text()).toBe('') + }) + + it('throws when the definition has no built SPA', () => { + const def = makeDef('') + def.cli = undefined + expect(() => createDevframeNextHandler(def)).toThrow(/cli\.distDir/) + }) +}) diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 6646b7b4..9462b69f 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "jsx": "react-jsx", "lib": ["esnext", "dom"], - "types": ["node"] + "types": ["node", "react"] }, "include": ["src", "tsdown.config.ts"], "exclude": ["dist", "node_modules"] diff --git a/packages/next/tsdown.config.ts b/packages/next/tsdown.config.ts index 534d0843..4afcf516 100644 --- a/packages/next/tsdown.config.ts +++ b/packages/next/tsdown.config.ts @@ -1,10 +1,28 @@ import { defineConfig } from 'tsdown' -export default defineConfig({ - entry: './src/index.ts', - platform: 'node', - tsconfig: '../../tsconfig.base.json', - clean: true, - dts: true, - outExtensions: () => ({ dts: '.d.mts' }), -}) +const tsconfig = '../../tsconfig.base.json' + +export default defineConfig([ + // Node entry — the route handler + host + config helper. + { + entry: { index: 'src/index.ts' }, + platform: 'node', + tsconfig, + clean: true, + dts: true, + outExtensions: () => ({ dts: '.d.mts' }), + }, + // Browser entry — the React client surface. React and devframe's client stay + // external so the consuming app provides them. + { + entry: { client: 'src/client.tsx' }, + platform: 'browser', + tsconfig, + clean: false, + dts: true, + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + deps: { + neverBundle: ['react', 'react-dom', 'react/jsx-runtime', 'devframe/client'], + }, + }, +]) diff --git a/plans/notes/devframes-next-proposal.md b/plans/notes/devframes-next-proposal.md index 3822cdd7..2943a2e7 100644 --- a/plans/notes/devframes-next-proposal.md +++ b/plans/notes/devframes-next-proposal.md @@ -1,9 +1,9 @@ # Proposal: `@devframes/next` host-integration package -> Spike output for plan 027. Status: **prototype landed** as a private package -> (`packages/next`) and proven by refactoring `examples/minimal-next-devframe-hub` -> onto it. This document is the design write-up + open questions a follow-up plan -> uses to promote it to a published package. +> Origin: spike for plan 027. Status: **promoted** — `@devframes/next` now ships +> as a published (experimental-tag) package with the single-plugin handler, the +> React client surface, and API snapshots in place. This document is the design +> write-up; the "Promotion" section below records how each open question landed. ## Problem @@ -122,37 +122,42 @@ STOP condition (contract divergence) does not trip. `503`→`200` after `setConnectionMeta`, and meta taking precedence over SPA fallback for both the plugin and hub bases. -## Open questions (for the promotion plan) - -1. **`createDevframeNextHandler(definition)` convenience wrapper.** The - single-plugin case (mount one `def.cli.distDir` + run a side-car dev server, - mirroring `viteDevBridge`'s two modes) is not implemented — there is no - single-plugin Next example to test it against, so building it now would be - untested code. Add it (and an example) when `plugins/git` grows a `/next` - entry (the sibling of plan 029's `/vite` work). -2. **Client surface.** Nuxt ships a client plugin exposing `$rpc`. The Next - equivalent — a shipped `RpcProvider` / `useRpc` React context wrapping - `connectDevframe()`, and a `` / layout helper — is - deferred; the hub example still hand-writes its provider in `page.tsx`. Decide - whether these belong in `@devframes/next` (React peer) or stay app-owned. -3. **Publishing shape.** Currently `private: true`. To publish: drop `private`, - which pulls it into the `tsnapi` API snapshot (`tests/exports.test.ts` filters - out private packages) — a snapshot will need to be generated and reviewed. - Confirm `next` as an optional peer (already declared) and the export map - mirrors the Axis-A baseline. -4. **404 body.** h3's default handler returns a small JSON error body for an - unmounted path; the old route returned an empty 404. Harmless for asset loads, - but decide whether to install a bare-404 error handler for parity. +## Promotion (how each open question landed) + +1. **`createDevframeNextHandler(definition, options)` — implemented.** The + single-plugin wrapper composes `createDevframeNextHost` (SPA + meta serving) + with a bridge-mode `createDevServer` side-car (WS on its own port, advertised + at `/__connection.json`), mirroring `viteDevBridge`'s bridge mode. It + returns `{ fetch, ready, close }` and throws early when `cli.distDir` is + missing. Covered by `packages/next/test/handler.test.ts` (boots a real + side-car against a temp `distDir` and asserts SPA serving, SPA fallback, the + WS-port meta, and a bare 404). +2. **Client surface — implemented** at `@devframes/next/client` (React peer, + optional). `RpcProvider` calls `connectDevframe()` once and provides the + client; `useRpc()` reads it (throwing outside a provider). The `'use client'` + directive is preserved through the browser build. A theme/layout helper was + deliberately left out — theming is design-system-specific and stays + app-owned. +3. **Publishing shape — done.** `private` dropped; `publishConfig.tag: + "experimental"` publishes under the `experimental` tag, not `latest`. `next` + and `react` are optional peers; exports are `.` (Node) + `./client` + (browser). tsnapi snapshots generated under + `tests/__snapshots__/tsnapi/@devframes/next/` (`index` + `client`). +4. **404 body — parity added.** The host `fetch` normalizes any miss to a + body-less `404`, matching a plain static server (h3's default JSON error body + is dropped). 5. **`next-runtime-snapshot` needs no bridge.** It uses Next only as a static-SPA builder while devframe owns the server via `createCac`/`createBuild`; the - bridge doesn't apply. Leave it as-is (documented here so a future reader - doesn't try to "unify" the two Next examples). + bridge doesn't apply. Left as-is (recorded so a future reader doesn't try to + "unify" the two Next examples). ## Files -- `packages/next/` — `src/host.ts` (`createDevframeNextHost`), `src/config.ts` - (`withDevframe`), `src/index.ts`, plus `package.json` / `tsconfig.json` / - `tsdown.config.ts` mirroring `packages/nuxt`. -- `tsconfig.base.json` — `@devframes/next` source alias. +- `packages/next/` — `src/host.ts` (`createDevframeNextHost`), `src/handler.ts` + (`createDevframeNextHandler`), `src/client.tsx` (`RpcProvider` / `useRpc`), + `src/config.ts` (`withDevframe`), `src/index.ts`, plus `package.json` (public, + experimental tag) / `tsconfig.json` / `tsdown.config.ts` (node + browser). +- `tsconfig.base.json` + `alias.ts` — `@devframes/next` and `/client` source aliases. +- `vitest.config.ts` — `packages/next` project; `tests/__snapshots__/tsnapi/@devframes/next/` API snapshots. - `examples/minimal-next-devframe-hub/` — host module, both route handlers, and - `next.config.mjs` refactored onto the bridge; `@devframes/next` added as a dep. + `next.config.mjs` on the bridge; `@devframes/next` as a dep. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a430d41..8496d320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,15 +1013,21 @@ importers: specifier: catalog:deps version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) next: - specifier: ^14.0.0 || ^15.0.0 - version: 15.5.22(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^14.0.0 || ^15.0.0 || ^16.0.0 + version: 16.2.11(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@types/node': specifier: catalog:types version: 26.1.1 + '@types/react': + specifier: catalog:types + version: 19.2.17 devframe: specifier: workspace:* version: link:../devframe + react: + specifier: catalog:frontend + version: 19.2.8 tsdown: specifier: catalog:build version: 0.22.14(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -2570,43 +2576,21 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@15.5.22': - resolution: {integrity: sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==} - '@next/env@16.2.11': resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==} - '@next/swc-darwin-arm64@15.5.22': - resolution: {integrity: sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@16.2.11': resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.22': - resolution: {integrity: sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@16.2.11': resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.22': - resolution: {integrity: sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@next/swc-linux-arm64-gnu@16.2.11': resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==} engines: {node: '>= 10'} @@ -2614,13 +2598,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.22': - resolution: {integrity: sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@next/swc-linux-arm64-musl@16.2.11': resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==} engines: {node: '>= 10'} @@ -2628,13 +2605,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.22': - resolution: {integrity: sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@next/swc-linux-x64-gnu@16.2.11': resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==} engines: {node: '>= 10'} @@ -2642,13 +2612,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.22': - resolution: {integrity: sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - '@next/swc-linux-x64-musl@16.2.11': resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==} engines: {node: '>= 10'} @@ -2656,24 +2619,12 @@ packages: os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.22': - resolution: {integrity: sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@16.2.11': resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.22': - resolution: {integrity: sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@16.2.11': resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==} engines: {node: '>= 10'} @@ -7735,27 +7686,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@15.5.22: - resolution: {integrity: sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - next@16.2.11: resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==} engines: {node: '>=20.9.0'} @@ -10980,55 +10910,29 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@15.5.22': {} - '@next/env@16.2.11': {} - '@next/swc-darwin-arm64@15.5.22': - optional: true - '@next/swc-darwin-arm64@16.2.11': optional: true - '@next/swc-darwin-x64@15.5.22': - optional: true - '@next/swc-darwin-x64@16.2.11': optional: true - '@next/swc-linux-arm64-gnu@15.5.22': - optional: true - '@next/swc-linux-arm64-gnu@16.2.11': optional: true - '@next/swc-linux-arm64-musl@15.5.22': - optional: true - '@next/swc-linux-arm64-musl@16.2.11': optional: true - '@next/swc-linux-x64-gnu@15.5.22': - optional: true - '@next/swc-linux-x64-gnu@16.2.11': optional: true - '@next/swc-linux-x64-musl@15.5.22': - optional: true - '@next/swc-linux-x64-musl@16.2.11': optional: true - '@next/swc-win32-arm64-msvc@15.5.22': - optional: true - '@next/swc-win32-arm64-msvc@16.2.11': optional: true - '@next/swc-win32-x64-msvc@15.5.22': - optional: true - '@next/swc-win32-x64-msvc@16.2.11': optional: true @@ -16280,30 +16184,6 @@ snapshots: negotiator@1.0.0: {} - next@15.5.22(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@next/env': 15.5.22 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001806 - postcss: 8.4.31 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@10.2.2))(react@19.2.8) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.22 - '@next/swc-darwin-x64': 15.5.22 - '@next/swc-linux-arm64-gnu': 15.5.22 - '@next/swc-linux-arm64-musl': 15.5.22 - '@next/swc-linux-x64-gnu': 15.5.22 - '@next/swc-linux-x64-musl': 15.5.22 - '@next/swc-win32-arm64-msvc': 15.5.22 - '@next/swc-win32-x64-msvc': 15.5.22 - '@playwright/test': 1.61.1 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@16.2.11(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.11 diff --git a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts new file mode 100644 index 00000000..de5fbb74 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/next/client` + */ +// #region Interfaces +export interface RpcProviderProps { + children: ReactNode; + baseURL?: string; + connectionMeta?: ConnectionMeta; + fallback?: ReactNode; +} +// #endregion + +// #region Functions +export declare function RpcProvider({ children, baseURL, connectionMeta, fallback }: RpcProviderProps): ReactNode; +export declare function useRpc(): DevframeRpcClient; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js new file mode 100644 index 00000000..3811da8e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/next/client` + */ +// #region Functions +export function RpcProvider(_) {} +export function useRpc() {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts new file mode 100644 index 00000000..2fd71f27 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -0,0 +1,39 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/next` + */ +// #region Interfaces +export interface CreateDevframeNextHandlerOptions { + base?: string; + host?: string; + port?: number; + flags?: Record; + auth?: CreateDevServerOptions['auth']; + resolveOrigin?: () => string; + getStorageDir?: (_: DevframeStorageScope) => string; +} +export interface CreateDevframeNextHostOptions { + resolveOrigin: () => string; + getStorageDir: (_: DevframeStorageScope) => string; + connectionMeta?: ConnectionMeta; +} +export interface DevframeNextConfig { + skipTrailingSlashRedirect?: boolean; + [key: string]: unknown; +} +export interface DevframeNextHandler { + fetch: (_: Request) => Promise; + ready: Promise; + close: () => Promise; +} +export interface DevframeNextHost { + host: DevframeHost; + fetch: (_: Request) => Promise; + setConnectionMeta: (_: ConnectionMeta) => void; +} +// #endregion + +// #region Functions +export declare function createDevframeNextHandler(_: DevframeDefinition, _?: CreateDevframeNextHandlerOptions): DevframeNextHandler; +export declare function createDevframeNextHost(_: CreateDevframeNextHostOptions): DevframeNextHost; +export declare function withDevframe(_?: T): T; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.js new file mode 100644 index 00000000..61cef601 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/next` + */ +// #region Functions +export function createDevframeNextHandler(_, _) {} +export function createDevframeNextHost(_) {} +export function withDevframe(_) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 1ea46c79..e6c6a69f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -124,6 +124,12 @@ "@devframes/nuxt": [ "./packages/nuxt/src/index.ts" ], + "@devframes/next/client": [ + "./packages/next/src/client.tsx" + ], + "@devframes/next": [ + "./packages/next/src/index.ts" + ], "@devframes/json-render/core": [ "./packages/json-render/src/core.ts" ], diff --git a/vitest.config.ts b/vitest.config.ts index 91236332..f08a2b1e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ 'plugins/a11y', 'plugins/messages', 'examples/minimal-next-devframe-hub', + 'packages/next', { test: { name: 'tests', From 66a78c2a46482aac6fa79ac29ea7b037b2f1fd35 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 06:55:48 +0000 Subject: [PATCH 4/5] feat(next): evolve client surface + adopt it in next-runtime-snapshot - Drop the experimental npm dist-tag (publishConfig); the package stays flagged experimental in its description and docs only, matching the other packages. - RpcProvider now renders children eagerly and exposes live connection state: useRpc() returns the client or null, useRpcStatus() returns { status, error } (subscribed to the client's connection:status / connection:error events). - Apply @devframes/next/client to examples/next-runtime-snapshot: its connect provider is now a thin adapter over the package (the host/handler adapter stays N/A there since devframe owns the server via createCac). Wire the turbo build edge and regenerate the client API snapshot. --- docs/helpers/next.md | 17 ++-- examples/next-runtime-snapshot/package.json | 1 + .../src/client/app/components/connect.tsx | 45 ++++----- packages/next/package.json | 4 - packages/next/src/client.tsx | 93 +++++++++++++------ plans/notes/devframes-next-proposal.md | 31 ++++--- pnpm-lock.yaml | 3 + .../@devframes/next/client.snapshot.d.ts | 14 ++- .../tsnapi/@devframes/next/client.snapshot.js | 1 + turbo.json | 2 +- 10 files changed, 126 insertions(+), 85 deletions(-) diff --git a/docs/helpers/next.md b/docs/helpers/next.md index 0b4c5ebd..22bd31d4 100644 --- a/docs/helpers/next.md +++ b/docs/helpers/next.md @@ -5,7 +5,7 @@ outline: deep # Next Helper > [!WARNING] -> Experimental. `@devframes/next` is published under the `experimental` npm tag (`npm i @devframes/next@experimental`) while its API settles. Expect changes before a stable release. +> 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. @@ -95,7 +95,7 @@ export async function GET(request: Request): Promise { ## 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. +`@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' @@ -106,17 +106,22 @@ export function Providers({ children }: { children: React.ReactNode }) { } ``` +`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 } from '@devframes/next/client' +import { useRpc, useRpcStatus } from '@devframes/next/client' export function Panel() { - const rpc = useRpc().scope('my-tool:') - // rpc.call('get-payload'), rpc.sharedState, … + const rpc = useRpc()?.scope('my-tool:') + const { status, error } = useRpcStatus() + if (!rpc) + return

{error ? `connection failed — ${error.message}` : 'connecting…'}

+ // rpc.rpc.call('get-payload'), rpc.sharedState, … } ``` -`RpcProvider` renders its `fallback` (default `null`) until the client connects, so `useRpc()` always returns a live client. Theming and layout stay app-owned. +Both hooks throw outside a ``. Theming and layout stay app-owned. ## Runtime diff --git a/examples/next-runtime-snapshot/package.json b/examples/next-runtime-snapshot/package.json index 40eaf0f1..c4a9459a 100644 --- a/examples/next-runtime-snapshot/package.json +++ b/examples/next-runtime-snapshot/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@antfu/design": "catalog:frontend", + "@devframes/next": "workspace:*", "cac": "catalog:deps", "colorjs.io": "catalog:frontend", "devframe": "workspace:*", diff --git a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx index 9f10d8a1..cbb86b44 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx @@ -2,8 +2,8 @@ import type { DevframeScopedClientContext } from 'devframe/client' import type { ReactNode } from 'react' -import { connectDevframe } from 'devframe/client' -import { createContext, useContext, useEffect, useState } from 'react' +import { RpcProvider as DevframeRpcProvider, useRpc as useDevframeRpc, useRpcStatus } from '@devframes/next/client' +import { useMemo } from 'react' // Inlined (not imported from the server `rpc/index.ts`) so the client // bundle stays free of node-only server code. @@ -16,33 +16,20 @@ interface ConnectionState { error: string | null } -const RpcContext = createContext({ ctx: null, error: null }) - -export function useRpc(): ConnectionState { - return useContext(RpcContext) -} - +/** + * Connect to the RPC backend via `@devframes/next/client` — the connect + + * status machinery lives in the package now; this file only scopes the client + * to this tool's namespace and reshapes the status for the local UI. + */ export function RpcProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ ctx: null, error: null }) - - useEffect(() => { - let cancelled = false - connectDevframe().then( - (rpc) => { - if (!cancelled) - setState({ ctx: rpc.scope(NAMESPACE), error: null }) - }, - (err: unknown) => { - if (cancelled) - return - const message = err instanceof Error ? err.message : String(err) - setState({ ctx: null, error: message }) - }, - ) - return () => { - cancelled = true - } - }, []) + return {children} +} - return {children} +export function useRpc(): ConnectionState { + const rpc = useDevframeRpc() + const { error } = useRpcStatus() + // `rpc` is stable once resolved, so memoizing on it keeps `ctx` referentially + // stable across renders (the snapshot components depend on `ctx` identity). + const ctx = useMemo(() => (rpc ? rpc.scope(NAMESPACE) : null), [rpc]) + return { ctx, error: error ? error.message : null } } diff --git a/packages/next/package.json b/packages/next/package.json index 333835c4..c37515cd 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -34,10 +34,6 @@ "files": [ "dist" ], - "publishConfig": { - "access": "public", - "tag": "experimental" - }, "scripts": { "build": "tsdown", "watch": "tsdown --watch", diff --git a/packages/next/src/client.tsx b/packages/next/src/client.tsx index 7f9223dc..e83d123b 100644 --- a/packages/next/src/client.tsx +++ b/packages/next/src/client.tsx @@ -1,12 +1,23 @@ 'use client' -import type { DevframeRpcClient } from 'devframe/client' +import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client' import type { ConnectionMeta } from 'devframe/types' import type { ReactNode } from 'react' import { connectDevframe } from 'devframe/client' import { createContext, useContext, useEffect, useState } from 'react' -const RpcContext = createContext(null) +export interface DevframeRpcState { + /** The connected client, or `null` while the initial connect is in flight. */ + rpc: DevframeRpcClient | null + /** Live connection status — `'connecting'` until the client resolves. */ + status: DevframeConnectionStatus + /** The latest connection error, or `null`. */ + error: Error | null +} + +const RpcContext = createContext(null) + +const INITIAL: DevframeRpcState = { rpc: null, status: 'connecting', error: null } export interface RpcProviderProps { children: ReactNode @@ -21,21 +32,16 @@ export interface RpcProviderProps { * fetch entirely (e.g. when the host already knows the WS endpoint). */ connectionMeta?: ConnectionMeta - /** - * Rendered while the client is connecting. Children mount only once the RPC - * client is ready, so {@link useRpc} always returns a live client. Defaults - * to `null`. - */ - fallback?: ReactNode } /** - * Connect to the devframe RPC backend once and provide the client to the tree. + * Connect to the devframe RPC backend once and provide the client — plus live + * connection status — to the tree. The React counterpart to `@devframes/nuxt`'s + * client plugin. * - * The React counterpart to `@devframes/nuxt`'s client plugin: it calls - * `connectDevframe()` on mount and exposes the result through {@link useRpc}. - * Being a client component, drop it into a Next layout or page and read the - * client from any descendant. + * Children render immediately (before the connection resolves), so your shell + * and a connection indicator stay visible throughout; read the client with + * {@link useRpc} and the status with {@link useRpcStatus}. * * ```tsx * 'use client' @@ -50,37 +56,66 @@ export function RpcProvider({ children, baseURL = './', connectionMeta, - fallback = null, }: RpcProviderProps): ReactNode { - const [rpc, setRpc] = useState(null) + const [state, setState] = useState(INITIAL) useEffect(() => { let active = true - void connectDevframe({ baseURL, connectionMeta }).then((client) => { - if (active) - setRpc(client) - }) + let client: DevframeRpcClient | undefined + const sync = (): void => { + if (active && client) + setState({ rpc: client, status: client.status, error: client.connectionError }) + } + + const offs: Array<() => void> = [] + void connectDevframe({ baseURL, connectionMeta }).then( + (c) => { + if (!active) + return + client = c + sync() + offs.push(c.events.on('connection:status', sync)) + offs.push(c.events.on('connection:error', sync)) + }, + (err: unknown) => { + if (active) + setState({ rpc: null, status: 'error', error: err instanceof Error ? err : new Error(String(err)) }) + }, + ) + return () => { active = false + for (const off of offs) + off() } // `connectionMeta` is a plain descriptor; re-connect only when the base changes. }, [baseURL]) - if (!rpc) - return fallback + return {children} +} - return {children} +function useDevframeState(): DevframeRpcState { + const state = useContext(RpcContext) + if (!state) + throw new Error('[@devframes/next] useRpc()/useRpcStatus() must be called inside a .') + return state } /** - * Read the connected {@link DevframeRpcClient} provided by {@link RpcProvider}. - * Scope it to your tool's RPC namespace with `useRpc().scope('my-tool:…')`. + * Read the connected {@link DevframeRpcClient}, or `null` while connecting. + * Scope it to your tool's RPC namespace with `useRpc()?.scope('my-tool:')`. * * Throws when called outside a ``. */ -export function useRpc(): DevframeRpcClient { - const rpc = useContext(RpcContext) - if (!rpc) - throw new Error('[@devframes/next] useRpc() must be called inside a .') - return rpc +export function useRpc(): DevframeRpcClient | null { + return useDevframeState().rpc +} + +/** + * Read the live connection `status` and latest `error`, for a connection + * indicator. Throws when called outside a ``. + */ +export function useRpcStatus(): { status: DevframeConnectionStatus, error: Error | null } { + const { status, error } = useDevframeState() + return { status, error } } diff --git a/plans/notes/devframes-next-proposal.md b/plans/notes/devframes-next-proposal.md index 2943a2e7..50faf222 100644 --- a/plans/notes/devframes-next-proposal.md +++ b/plans/notes/devframes-next-proposal.md @@ -133,23 +133,28 @@ STOP condition (contract divergence) does not trip. side-car against a temp `distDir` and asserts SPA serving, SPA fallback, the WS-port meta, and a bare 404). 2. **Client surface — implemented** at `@devframes/next/client` (React peer, - optional). `RpcProvider` calls `connectDevframe()` once and provides the - client; `useRpc()` reads it (throwing outside a provider). The `'use client'` - directive is preserved through the browser build. A theme/layout helper was - deliberately left out — theming is design-system-specific and stays - app-owned. -3. **Publishing shape — done.** `private` dropped; `publishConfig.tag: - "experimental"` publishes under the `experimental` tag, not `latest`. `next` - and `react` are optional peers; exports are `.` (Node) + `./client` - (browser). tsnapi snapshots generated under + optional). `RpcProvider` calls `connectDevframe()` once and renders children + eagerly (so a shell + connection indicator stay visible while connecting); + `useRpc()` returns the client or `null`, and `useRpcStatus()` exposes the live + `{ status, error }` (subscribed to the client's `connection:status` / + `connection:error` events). The `'use client'` directive is preserved through + the browser build. A theme/layout helper was deliberately left out — theming + is design-system-specific and stays app-owned. +3. **Publishing shape — done.** `private` dropped so the package publishes with + the rest of the workspace; it's flagged experimental in its description and + the docs (no npm dist-tag). `next` and `react` are optional peers; exports are + `.` (Node) + `./client` (browser). tsnapi snapshots generated under `tests/__snapshots__/tsnapi/@devframes/next/` (`index` + `client`). 4. **404 body — parity added.** The host `fetch` normalizes any miss to a body-less `404`, matching a plain static server (h3's default JSON error body is dropped). -5. **`next-runtime-snapshot` needs no bridge.** It uses Next only as a static-SPA - builder while devframe owns the server via `createCac`/`createBuild`; the - bridge doesn't apply. Left as-is (recorded so a future reader doesn't try to - "unify" the two Next examples). +5. **`next-runtime-snapshot` — client surface adopted; host part N/A.** It uses + Next only as a static-SPA builder while devframe owns the server via + `createCac`/`createBuild`, so the host/handler adapter (route handlers) still + doesn't apply. Its hand-rolled connect provider, however, was migrated onto + `@devframes/next/client`: `connect.tsx` is now a thin adapter that delegates + connect + status to the package and only scopes the client to the example's + namespace — a second consumer that validated the eager-render + status API. ## Files diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8496d320..7ba22b08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,6 +687,9 @@ importers: '@antfu/design': specifier: catalog:frontend version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@devframes/next': + specifier: workspace:* + version: link:../../packages/next cac: specifier: catalog:deps version: 7.0.0 diff --git a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts index de5fbb74..7adaaf0f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts @@ -2,15 +2,23 @@ * Generated by tsnapi — public API snapshot of `@devframes/next/client` */ // #region Interfaces +export interface DevframeRpcState { + rpc: DevframeRpcClient | null; + status: DevframeConnectionStatus; + error: Error | null; +} export interface RpcProviderProps { children: ReactNode; baseURL?: string; connectionMeta?: ConnectionMeta; - fallback?: ReactNode; } // #endregion // #region Functions -export declare function RpcProvider({ children, baseURL, connectionMeta, fallback }: RpcProviderProps): ReactNode; -export declare function useRpc(): DevframeRpcClient; +export declare function RpcProvider({ children, baseURL, connectionMeta }: RpcProviderProps): ReactNode; +export declare function useRpc(): DevframeRpcClient | null; +export declare function useRpcStatus(): { + status: DevframeConnectionStatus; + error: Error | null; +}; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js index 3811da8e..22c9a18a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js @@ -4,4 +4,5 @@ // #region Functions export function RpcProvider(_) {} export function useRpc() {} +export function useRpcStatus() {} // #endregion \ No newline at end of file diff --git a/turbo.json b/turbo.json index ca0b6e39..ea22e7d3 100644 --- a/turbo.json +++ b/turbo.json @@ -110,7 +110,7 @@ }, "next-runtime-snapshot-example#build": { "outputLogs": "new-only", - "dependsOn": ["devframe#build"], + "dependsOn": ["devframe#build", "@devframes/next#build"], "outputs": ["dist/**"] }, "@devframes/plugin-git#build": { From 42b112fbd6aa355faebbdcfe0b07561464016765 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 07:08:46 +0000 Subject: [PATCH 5/5] docs(plans): drop the superseded devframes-next proposal note The spike proposal is fully realized in the shipped @devframes/next package, its tests/API snapshots, and docs/helpers/next.md; remove the stale note and point the plans/README row at the shipped artifacts instead. --- plans/README.md | 2 +- plans/notes/devframes-next-proposal.md | 168 ------------------------- 2 files changed, 1 insertion(+), 169 deletions(-) delete mode 100644 plans/notes/devframes-next-proposal.md diff --git a/plans/README.md b/plans/README.md index 74ed4920..68e0b559 100644 --- a/plans/README.md +++ b/plans/README.md @@ -39,7 +39,7 @@ changes are allowed as long as they're marked). | 024 | Batch stream-replay into one frame | perf | P3 | M | 009 | TODO | | 025 | Cache falsy RPC results (presence check) | bug | P3 | S | — | DONE | | 026 | Clear dev/docs-only dependency advisories | deps | P3 | S | 002 | TODO | -| 027 | Spike: `@devframes/next` host-integration package | direction | P3 | M | — | DONE (spike — proposal `plans/notes/devframes-next-proposal.md`; prototype `packages/next` (private), hub example refactored onto it) | +| 027 | Spike: `@devframes/next` host-integration package | direction | P3 | M | — | DONE (shipped `@devframes/next`, experimental — see `docs/helpers/next.md`; hub + next-runtime-snapshot examples adopt it) | | 029 | Bring `@devframes/plugin-git` to the host baseline | direction/dx | P3 | S-M | — | TODO | | 030 | Spike: server-side auth enforcement ⚠️ | security | P2 | L | 003, 007, 015 | DONE | diff --git a/plans/notes/devframes-next-proposal.md b/plans/notes/devframes-next-proposal.md deleted file mode 100644 index 50faf222..00000000 --- a/plans/notes/devframes-next-proposal.md +++ /dev/null @@ -1,168 +0,0 @@ -# Proposal: `@devframes/next` host-integration package - -> Origin: spike for plan 027. Status: **promoted** — `@devframes/next` now ships -> as a published (experimental-tag) package with the single-plugin handler, the -> React client surface, and API snapshots in place. This document is the design -> write-up; the "Promotion" section below records how each open question landed. - -## Problem - -Both Next examples hand-rolled static serving that re-implements what -`packages/devframe/src/utils/serve-static.ts` already owns and tests. The worst -offender was `examples/minimal-next-devframe-hub`, whose App Router catch-all -route (`app/__[id]/[[...path]]/route.ts`, 109 lines) carried its own -content-type map, path-traversal guard, directory→`index.html` resolution, SPA -fallback, and file streaming — plus a `STATIC_MOUNTS` registry, a -`CONNECTION_META_BASES` matcher, and a by-hand `DevframeHost` in its host module -(~90 more lines). `@devframes/nuxt` existed with no Next counterpart, an -asymmetry versus the documented Axis-A targets. - -`@devframes/nuxt` stays tiny (~190 lines) because Nuxt is Vite-based, so it -reuses devframe's `viteDevBridge` verbatim. **Next is webpack/Turbopack, so it -cannot reuse that path** — it must own route handlers. That is the whole reason -the boilerplate existed and the reason a dedicated bridge is justified. - -## What a Next host actually needs - -The example is a **hub** (many devframes mounted at once), not a single plugin. -Its `DevframeHost.mountStatic` / `mountConnectionMeta` are called once per -mounted devframe (git, terminals, inspect, a11y, …) plus the a11y agent module, -accumulating a set of `(base → distDir)` static mounts and a set of -connection-meta bases. So the bridge's core job is not "serve one definition's -`distDir`" (plan 027's first-draft `createDevframeNextHandler(definition)` -signature) — it is: - -> provide a `DevframeHost` whose mount calls accumulate into **one WHATWG-`fetch` -> handler** that a Next catch-all route delegates to. - -## API (implemented) - -```ts -import { createDevframeNextHost, withDevframe } from '@devframes/next' - -const { host, fetch, setConnectionMeta } = createDevframeNextHost({ - resolveOrigin: () => 'http://localhost:3000', - getStorageDir: scope => /* workspace | project | global dir */, -}) -``` - -- **`createDevframeNextHost(options) → { host, fetch, setConnectionMeta }`** - - `host: DevframeHost` — hand to `createHubContext` / `createHostContext`. - `mountStatic(base, dir)` mounts `dir` on an internal h3 app at `base`; - `mountConnectionMeta(base)` registers a meta base. - - `fetch(request: Request) => Promise` — the single handler both - App Router routes delegate to. Serves every mounted SPA through devframe's - shared `serveStaticHandler` (SPA fallback, content types, traversal guard - all reused) and answers `/__connection.json` for each registered base - **before** the static handler runs, so an SPA fallback can't swallow the - discovery fetch. - - `setConnectionMeta(meta)` — publish the live meta once the RPC/WS port is - known. Until then meta requests return `503` (a racing client retries rather - than caching a wrong endpoint). -- **`withDevframe(nextConfig) → nextConfig`** — applies the host-mode Next - setting `skipTrailingSlashRedirect: true` (so mounted SPAs' relative assets - under `/__/` resolve rather than 404 on Next's trailing-slash redirect), - preserving everything else. - -### How the reuse works - -`serveStaticHandler` is an h3 v2 `EventHandler` and h3 v2's `H3` instance is -itself a WHATWG-`fetch` handler (`app.fetch(request)`), which is exactly what an -App Router route returns. So the bridge mounts one static sub-app per base -(`app.mount(base, sub)` — h3 strips the base and matches on segment boundaries, -giving the same longest-prefix behavior the hand-rolled registry did) and -delegates. No static-serving logic is re-implemented; the package is ~130 lines. - -### Route handler after the refactor - -```ts -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -export async function GET(request: Request): Promise { - const hub = await ensureMinimalNextDevframeHub() - return hub.fetch(request) -} -``` - -Both the catch-all SPA route and the `__hub/__connection.json` route collapse to -this (the host registers `/__hub` as a meta base so the one `fetch` covers the -hub's own discovery too). - -## Runtime choice: Node - -The route pins `export const runtime = 'nodejs'` because `serveStaticHandler` -streams from the filesystem (`node:fs` + `node:stream`). Edge is out of scope: -serving a built SPA off disk is inherently a Node concern, and the side-car -RPC/WS server the hub starts is Node-only regardless. An Edge story would need a -fetch-native asset source (e.g. imported/bundled assets) and is not required by -either example — noted as a non-goal, not a limitation to design around now. - -## Connection-descriptor contract - -Consistent across both Next examples and every other adapter: the live shape is -`{ backend: 'websocket', websocket: }` and the baked -(static-build) shape is `{ backend: 'static' }`. The bridge serves whatever meta -`setConnectionMeta` is handed, so it imposes no new contract. Plan 027's second -STOP condition (contract divergence) does not trip. - -## Acceptance (verified in the spike) - -- `pnpm --filter @devframes/next build` + `typecheck` — green; emits - `dist/index.mjs` + `dist/index.d.mts`. -- `pnpm typecheck` (all 21 workspace tasks, incl. the coverage guard) — green. -- `examples/minimal-next-devframe-hub` refactored onto the bridge: the 109-line - catch-all route, both hand-rolled registries, and the by-hand `DevframeHost` - are gone; both routes now delegate to `hub.fetch`. `pnpm --filter - minimal-next-devframe-hub build` (Next 16 / Turbopack) succeeds and the - example's 4 existing tests pass. -- Runtime smoke against a **real** built plugin SPA (`plugins/git/dist/client`) - confirmed: dir→`index.html`, direct file with correct content type, SPA - fallback for extensionless routes, `HEAD`, `404` for unmounted bases, meta - `503`→`200` after `setConnectionMeta`, and meta taking precedence over SPA - fallback for both the plugin and hub bases. - -## Promotion (how each open question landed) - -1. **`createDevframeNextHandler(definition, options)` — implemented.** The - single-plugin wrapper composes `createDevframeNextHost` (SPA + meta serving) - with a bridge-mode `createDevServer` side-car (WS on its own port, advertised - at `/__connection.json`), mirroring `viteDevBridge`'s bridge mode. It - returns `{ fetch, ready, close }` and throws early when `cli.distDir` is - missing. Covered by `packages/next/test/handler.test.ts` (boots a real - side-car against a temp `distDir` and asserts SPA serving, SPA fallback, the - WS-port meta, and a bare 404). -2. **Client surface — implemented** at `@devframes/next/client` (React peer, - optional). `RpcProvider` calls `connectDevframe()` once and renders children - eagerly (so a shell + connection indicator stay visible while connecting); - `useRpc()` returns the client or `null`, and `useRpcStatus()` exposes the live - `{ status, error }` (subscribed to the client's `connection:status` / - `connection:error` events). The `'use client'` directive is preserved through - the browser build. A theme/layout helper was deliberately left out — theming - is design-system-specific and stays app-owned. -3. **Publishing shape — done.** `private` dropped so the package publishes with - the rest of the workspace; it's flagged experimental in its description and - the docs (no npm dist-tag). `next` and `react` are optional peers; exports are - `.` (Node) + `./client` (browser). tsnapi snapshots generated under - `tests/__snapshots__/tsnapi/@devframes/next/` (`index` + `client`). -4. **404 body — parity added.** The host `fetch` normalizes any miss to a - body-less `404`, matching a plain static server (h3's default JSON error body - is dropped). -5. **`next-runtime-snapshot` — client surface adopted; host part N/A.** It uses - Next only as a static-SPA builder while devframe owns the server via - `createCac`/`createBuild`, so the host/handler adapter (route handlers) still - doesn't apply. Its hand-rolled connect provider, however, was migrated onto - `@devframes/next/client`: `connect.tsx` is now a thin adapter that delegates - connect + status to the package and only scopes the client to the example's - namespace — a second consumer that validated the eager-render + status API. - -## Files - -- `packages/next/` — `src/host.ts` (`createDevframeNextHost`), `src/handler.ts` - (`createDevframeNextHandler`), `src/client.tsx` (`RpcProvider` / `useRpc`), - `src/config.ts` (`withDevframe`), `src/index.ts`, plus `package.json` (public, - experimental tag) / `tsconfig.json` / `tsdown.config.ts` (node + browser). -- `tsconfig.base.json` + `alias.ts` — `@devframes/next` and `/client` source aliases. -- `vitest.config.ts` — `packages/next` project; `tests/__snapshots__/tsnapi/@devframes/next/` API snapshots. -- `examples/minimal-next-devframe-hub/` — host module, both route handlers, and - `next.config.mjs` on the bridge; `@devframes/next` as a dep.