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/.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..f5f2c8a2 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 + 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 new file mode 100644 index 00000000..22bd31d4 --- /dev/null +++ b/docs/helpers/next.md @@ -0,0 +1,134 @@ +--- +outline: deep +--- + +# Next Helper + +> [!WARNING] +> Experimental. `@devframes/next`'s API is still settling — expect changes before a stable release. + +`@devframes/next` hosts devframes from a Next.js App Router app. Next runs on webpack/Turbopack rather than Vite, so it hosts through a route handler instead of the [Vite Bridge](./vite-bridge): the package serves each devframe's SPA and its `__connection.json` from a single `fetch` handler your catch-all route delegates to, reusing devframe's own [`serveStaticHandler`](/adapters/dev) for SPA fallback, content types, and path-traversal guarding. + +It comes in three parts: + +1. **`withDevframe()`** — applies the one Next config setting a devframe host needs. +2. **`createDevframeNextHandler()`** — hosts a single devframe (the common case). +3. **`createDevframeNextHost()`** — the lower-level primitive for a hub mounting many devframes at once. + +Plus a React client surface at `@devframes/next/client`. + +## Config + +```ts [next.config.mjs] +import { withDevframe } from '@devframes/next' + +export default withDevframe({ + // ...your own Next config +}) +``` + +`withDevframe` sets `skipTrailingSlashRedirect: true` and preserves the rest. Mounted SPAs are served at `/__/` 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. + +| 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. | + +## Hosting a hub + +For many devframes at once, use `createDevframeNextHost()` with [`@devframes/hub`](/guide/hub). Its `host` accumulates every `mountStatic` / `mountConnectionMeta` call into one `fetch` handler: + +```ts [devframe/host.ts] +import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { createDevframeNextHost } from '@devframes/next' +import { startHttpAndWs } from 'devframe/node' + +const nextHost = createDevframeNextHost({ + resolveOrigin: () => 'http://localhost:3000', + getStorageDir: scope => resolveStorageDir(scope), +}) + +const context = await createHubContext({ host: nextHost.host, mode: 'dev' }) +await mountDevframe(context, myDevframe) +nextHost.host.mountConnectionMeta('/__hub') // the hub's own connection base + +const started = await startHttpAndWs({ context, port, auth: false }) +nextHost.setConnectionMeta({ backend: 'websocket', websocket: started.port }) + +export const hub = { fetch: nextHost.fetch } +``` + +```ts [app/__[id]/[[...path]]/route.ts] +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request): Promise { + return hub.fetch(request) // serves every mounted SPA + connection meta +} +``` + +`createDevframeNextHost` returns `{ host, fetch, setConnectionMeta }`: + +- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`. +- **`fetch(request)`** — the handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a `/__connection.json` discovery fetch; a miss returns a bare `404`. +- **`setConnectionMeta(meta)`** — publish the live meta once the RPC/WS port is known. Until then, meta requests answer `503` so a racing client retries rather than caching a wrong endpoint. + +## React client + +`@devframes/next/client` connects to the RPC backend and provides the client to your component tree — the React counterpart to `@devframes/nuxt`'s `$rpc` plugin. Children render immediately, so your shell and a connection indicator stay visible while the client connects. + +```tsx [app/providers.tsx] +'use client' +import { RpcProvider } from '@devframes/next/client' + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} +``` + +`useRpc()` returns the connected `DevframeRpcClient`, or `null` while connecting; scope it to your tool's namespace. `useRpcStatus()` returns the live `{ status, error }` for a connection indicator. + +```tsx [app/panel.tsx] +'use client' +import { useRpc, useRpcStatus } from '@devframes/next/client' + +export function Panel() { + const rpc = useRpc()?.scope('my-tool:') + const { status, error } = useRpcStatus() + if (!rpc) + return

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

+ // rpc.rpc.call('get-payload'), rpc.sharedState, … +} +``` + +Both hooks throw outside a ``. Theming and layout stay app-owned. + +## Runtime + +Route handlers that call `fetch` pin `export const runtime = 'nodejs'`: the static handler streams built SPA files from disk, and the side-car RPC/WS server is a Node process. + +## See also + +- [Vite Bridge](./vite-bridge) — the equivalent for Vite-based hosts +- [Hub](/guide/hub) — `createHubContext`, `mountDevframe`, and `DevframeHost` +- [minimal-next-devframe-hub](/examples/minimal-next-devframe-hub) — a full working host 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/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 new file mode 100644 index 00000000..c37515cd --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,66 @@ +{ + "name": "@devframes/next", + "type": "module", + "version": "0.7.14", + "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", + "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" + }, + "./client": { + "types": "./dist/client.d.mts", + "default": "./dist/client.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 || ^16.0.0", + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + } + }, + "dependencies": { + "h3": "catalog:deps" + }, + "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..e83d123b --- /dev/null +++ b/packages/next/src/client.tsx @@ -0,0 +1,121 @@ +'use 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' + +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 + /** + * 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 +} + +/** + * 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. + * + * 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' + * import { RpcProvider } from '@devframes/next/client' + * + * export function Providers({ children }: { children: React.ReactNode }) { + * return {children} + * } + * ``` + */ +export function RpcProvider({ + children, + baseURL = './', + connectionMeta, +}: RpcProviderProps): ReactNode { + const [state, setState] = useState(INITIAL) + + useEffect(() => { + let active = true + 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]) + + 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}, 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 | 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/packages/next/src/config.ts b/packages/next/src/config.ts new file mode 100644 index 00000000..69d6117b --- /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({ + * // ...your own Next config + * }) + * ``` + */ +export function withDevframe( + nextConfig: T = {} as T, +): T { + return { + ...nextConfig, + skipTrailingSlashRedirect: nextConfig.skipTrailingSlashRedirect ?? true, + } +} 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 new file mode 100644 index 00000000..4ea0d613 --- /dev/null +++ b/packages/next/src/host.ts @@ -0,0 +1,130 @@ +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) + } + + 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 { + 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..3318305e --- /dev/null +++ b/packages/next/src/index.ts @@ -0,0 +1,14 @@ +export type { DevframeNextConfig } from './config' +export { withDevframe } from './config' + +export type { + CreateDevframeNextHandlerOptions, + DevframeNextHandler, +} from './handler' +export { createDevframeNextHandler } from './handler' + +export type { + CreateDevframeNextHostOptions, + DevframeNextHost, +} from './host' +export { createDevframeNextHost } from './host' 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 new file mode 100644 index 00000000..9462b69f --- /dev/null +++ b/packages/next/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["esnext", "dom"], + "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 new file mode 100644 index 00000000..4afcf516 --- /dev/null +++ b/packages/next/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'tsdown' + +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/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. diff --git a/plans/README.md b/plans/README.md index ae221b0c..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 | — | TODO | +| 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9a72ad63..7ba22b08 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 @@ -684,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 @@ -1004,6 +1010,31 @@ 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 || ^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) + packages/nuxt: devDependencies: '@nuxt/kit': 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..7adaaf0f --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts @@ -0,0 +1,24 @@ +/** + * 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; +} +// #endregion + +// #region Functions +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 new file mode 100644 index 00000000..22c9a18a --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/next/client` + */ +// #region Functions +export function RpcProvider(_) {} +export function useRpc() {} +export function useRpcStatus() {} +// #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/turbo.json b/turbo.json index bfcc0fd8..ea22e7d3 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", @@ -105,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": { 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',