diff --git a/AGENTS.md b/AGENTS.md index fd9547c5..3708df78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,16 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co - Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`. - Dependencies go through the pnpm catalogs in `pnpm-workspace.yaml` (`cli`, `inlined`, `testing`, `types`) - add to a catalog and reference as `catalog:`, don't pin versions in `package.json`. +### Framework adapter packages: two scopes, one shape + +The framework adapter packages - `@devframes/vite`, `@devframes/nuxt`, `@devframes/next` - each split their surface into **two clearly-scoped subpaths**, because a consumer is always doing one of two distinct jobs. Keep all three parallel: + +- **`.../dev-spa`** - **build & dev-serve a single devframe's SPA** with that tool (the "I'm authoring one devframe" scope). Vite: the `devframeVitePlugin` / `devframeViteBridge` / `devframeVite` plugins. Next: `withDevframe` + `createDevframeNextHandler`, with its React client at `.../dev-spa/client`. Nuxt: the Nuxt module (registered as `modules: ['@devframes/nuxt/dev-spa']`). +- **`.../hub`** - **mount a whole `@devframes/hub` (many integrations) inside that tool** (the "I'm standing up devtools" scope). Wraps `initHub`, defaults the UI slot to `@devframes/hub-ui`'s `createUi()` (overridable via `ui`, or `ui: false` for headless), and ships a browser client helper at `.../hub/client` (a thin, lifecycle-managing wrapper over `@devframes/hub/client`'s `createDevframeClientHost`). `@devframes/hub` and `@devframes/hub-ui` are **optional peers** of these packages; `hub-ui` is loaded lazily (a bundler-ignored dynamic `import()` in the Next hub) so it stays optional and its `import.meta.url` asset lookups resolve at request time. +- **The bare root (`.`) throws** a helpful error pointing at the two subpaths - never put real code on it. +- **Vite and Nuxt already have native hub viewers** (`@vitejs/devtools-kit`, `@nuxt/devtools`), so `@devframes/vite/hub` and `@devframes/nuxt/hub` still work but emit a one-time `console.warn` recommending those (silence with `{ quiet: true }`). `@devframes/next/hub` has no native counterpart, so it warns nothing. +- The **full hub examples** (`examples/hub-vite`, `examples/hub-next`) consume `.../hub` for the server but keep hand-rolling their own client UI against `@devframes/hub/client` with `ui: false` - that hand-rolled client is the whole point of those reference hosts. The **minimal** ones (`examples/hub-*-minimal`) consume `.../hub` with the default `@devframes/hub-ui` and inject its `embedded.js`, needing no client code. + ### Design system All five built-in plugins - and every example under `examples/` - share one design system, [`@antfu/design`](https://github.com/antfu/design), so they look and feel like one product across frameworks (Git is React/Next, terminals is Svelte, code-server is Vue, inspect is Vue, a11y is Solid, the examples are Preact/Next/vanilla). It's a dev dependency consumed at build time: its UnoCSS preset and shipped styles drive every surface, and its Vue components are the canonical reference every framework matches. There is no shared internal design package - each app wires the preset itself and owns its own component ports. diff --git a/alias.ts b/alias.ts index 4b33ee2f..1443e4c9 100644 --- a/alias.ts +++ b/alias.ts @@ -39,7 +39,6 @@ export const alias = { 'devframe/adapters/cac': r('devframe/src/adapters/cac.ts'), 'devframe/adapters/dev': r('devframe/src/adapters/dev.ts'), 'devframe/adapters/build': r('devframe/src/adapters/build.ts'), - 'devframe/helpers/vite': r('devframe/src/helpers/vite.ts'), 'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'), 'devframe/initiate': r('devframe/src/adapters/initiate.ts'), 'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'), @@ -51,9 +50,19 @@ export const alias = { '@devframes/hub': r('hub/src/index.ts'), '@devframes/hub-ui': r('hub-ui/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), + '@devframes/nuxt/dev-spa': r('nuxt/src/dev-spa.ts'), + '@devframes/nuxt/hub/client': r('nuxt/src/hub-client.ts'), + '@devframes/nuxt/hub': r('nuxt/src/hub.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), - '@devframes/next/client': r('next/src/client.tsx'), + '@devframes/next/dev-spa/client': r('next/src/client.tsx'), + '@devframes/next/dev-spa': r('next/src/dev-spa.ts'), + '@devframes/next/hub/client': r('next/src/hub-client.tsx'), + '@devframes/next/hub': r('next/src/hub.ts'), '@devframes/next': r('next/src/index.ts'), + '@devframes/vite/dev-spa': r('vite/src/dev-spa.ts'), + '@devframes/vite/hub/client': r('vite/src/hub-client.ts'), + '@devframes/vite/hub': r('vite/src/hub.ts'), + '@devframes/vite': r('vite/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 e82430c7..6478d452 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -16,30 +16,60 @@ function listErrorCodes(prefix: string): string[] { .sort() } -function guideItems(prefix: string) { +function guideGroups(prefix: string) { return [ - { text: 'Introduction', link: `${prefix}/guide/` }, - { text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` }, - { text: 'Scoped Context', link: `${prefix}/guide/scoped-context` }, - { text: 'Cross-Plugin Services', link: `${prefix}/guide/services` }, - { text: 'RPC', link: `${prefix}/guide/rpc` }, - { text: 'Shared State', link: `${prefix}/guide/shared-state` }, - { text: 'JSON-Render', link: `${prefix}/guide/json-render` }, - { text: 'Streaming', link: `${prefix}/guide/streaming` }, - { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, - { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, - { text: 'Client', link: `${prefix}/guide/client` }, - { text: 'Transports', link: `${prefix}/guide/transports` }, - { text: 'Security', link: `${prefix}/guide/security` }, - { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, - { text: 'Hub', link: `${prefix}/guide/hub` }, - { text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` }, - { text: 'Deep Linking', link: `${prefix}/guide/deep-linking` }, - { text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` }, - { text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` }, - { text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` }, - { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, - ] satisfies DefaultTheme.NavItemWithLink[] + { + text: 'Fundamentals', + items: [ + { text: 'Introduction', link: `${prefix}/guide/` }, + { text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` }, + { text: 'Scoped Context', link: `${prefix}/guide/scoped-context` }, + { text: 'Cross-Plugin Services', link: `${prefix}/guide/services` }, + { text: 'RPC', link: `${prefix}/guide/rpc` }, + { text: 'Shared State', link: `${prefix}/guide/shared-state` }, + { text: 'Streaming', link: `${prefix}/guide/streaming` }, + { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, + { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, + ], + }, + { + text: 'Client & Security', + items: [ + { text: 'Client', link: `${prefix}/guide/client` }, + { text: 'Transports', link: `${prefix}/guide/transports` }, + { text: 'Security', link: `${prefix}/guide/security` }, + { text: 'Deep Linking', link: `${prefix}/guide/deep-linking` }, + ], + }, + { + text: 'JSON-Render', + items: [ + { text: 'JSON-Render', link: `${prefix}/guide/json-render` }, + { text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` }, + ], + }, + { + text: 'Hub', + items: [ + { text: 'Hub', link: `${prefix}/guide/hub` }, + { text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` }, + { text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` }, + { text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` }, + ], + }, + { + text: 'Recipes & Advanced', + items: [ + { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, + { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, + ], + }, + ] satisfies { text: string, items: DefaultTheme.NavItemWithLink[] }[] +} + +/** Flattened guide list — used by the top nav dropdown, which renders one level. */ +function guideItems(prefix: string) { + return guideGroups(prefix).flatMap(group => group.items) satisfies DefaultTheme.NavItemWithLink[] } function adaptersItems(prefix: string) { @@ -49,19 +79,25 @@ function adaptersItems(prefix: string) { { text: 'Dev', link: `${prefix}/adapters/dev` }, { text: 'Initiate (middleware)', link: `${prefix}/adapters/initiate` }, { text: 'Build', link: `${prefix}/adapters/build` }, - { text: 'Vite', link: `${prefix}/adapters/vite` }, + { text: 'Vite DevTools', link: `${prefix}/adapters/vite` }, { text: 'Embedded', link: `${prefix}/adapters/embedded` }, { text: 'MCP', link: `${prefix}/adapters/mcp` }, ] satisfies DefaultTheme.NavItemWithLink[] } +function frameworksItems(prefix: string) { + return [ + { text: 'Overview', link: `${prefix}/frameworks/` }, + { text: 'Vite', link: `${prefix}/frameworks/vite` }, + { text: 'Nuxt', link: `${prefix}/frameworks/nuxt` }, + { text: 'Next', link: `${prefix}/frameworks/next` }, + ] satisfies DefaultTheme.NavItemWithLink[] +} + function helpersItems(prefix: string) { return [ { text: 'Overview', link: `${prefix}/helpers/` }, { 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: 'Common RPC Functions', link: `${prefix}/helpers/common-rpc-functions` }, { text: 'Interactive Auth', link: `${prefix}/helpers/interactive-auth` }, ] satisfies DefaultTheme.NavItemWithLink[] @@ -103,12 +139,17 @@ export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { return [ { text: 'Guide', - items: guideItems(prefix), + // Labelled, collapsible subsections instead of one long flat list. + items: guideGroups(prefix).map(group => ({ ...group, collapsed: false })), }, { text: 'Adapters', items: adaptersItems(prefix), }, + { + text: 'Frameworks', + items: frameworksItems(prefix), + }, { text: 'Helpers', items: helpersItems(prefix), @@ -137,6 +178,7 @@ export function devframeNav(prefix = ''): DefaultTheme.NavItem[] { text: 'Adapters', items: [ ...adaptersItems(prefix), + { text: 'Frameworks', items: frameworksItems(prefix) }, { text: 'Helpers', items: helpersItems(prefix) }, ], }, diff --git a/docs/adapters/initiate.md b/docs/adapters/initiate.md index c7c8b211..43993b70 100644 --- a/docs/adapters/initiate.md +++ b/docs/adapters/initiate.md @@ -126,4 +126,4 @@ The instance **gates by default** — a handler mounted inside an app server is ## Relation to the other adapters -`createDevServer`, `viteDevBridge`, and `@devframes/next` are assembled from this instance internally — the handler is the one wiring underneath every serving path. To host **many** devframes behind one namespace with shared transport and docks, use the hub's counterpart: [`initHub`](../guide/hub-initiate). +`createDevServer`, `devframeViteBridge` (`@devframes/vite`), and `@devframes/next` are assembled from this instance internally — the handler is the one wiring underneath every serving path. To host **many** devframes behind one namespace with shared transport and docks, use the hub's counterpart: [`initHub`](../guide/hub-initiate). diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 1ff6e65f..29b5b73f 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -51,8 +51,8 @@ defineDevframe({ Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve: ```ts -// Vite -viteDevBridge(devframe, { devMiddleware: true, mcp: true }) +// Vite (@devframes/vite) +devframeViteBridge(devframe, { mcp: true }) // Next.js (@devframes/next) createDevframeNextHandler(devframe, { mcp: true }) @@ -90,6 +90,6 @@ It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — - **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. - **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. -Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The connector dials each instance's endpoint with the instance's own loopback origin, so it clears the route's origin gate without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. +Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `devframeViteBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The connector dials each instance's endpoint with the instance's own loopback origin, so it clears the route's origin gate without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF0033.md b/docs/errors/DF0033.md index 865363a0..e8268d94 100644 --- a/docs/errors/DF0033.md +++ b/docs/errors/DF0033.md @@ -10,7 +10,7 @@ outline: deep ## Cause -`viteDevBridge({ devMiddleware })` could not bring up the bridge dev server that pairs a host-served SPA (Vite, Nuxt, Astro, etc.) with devframe's RPC backend. Common reasons: +`devframeViteBridge()` (from `@devframes/vite`) could not bring up the bridge dev server that pairs a host-served SPA (Vite, Nuxt, Astro, etc.) with devframe's RPC backend. Common reasons: - The preferred port is in use and no fallback range was configured. - Calling `def.setup(ctx)` threw — the devframe's own setup logic surfaced an error. @@ -20,10 +20,10 @@ This is a soft warning — the surrounding Vite dev server keeps running, but th ## Fix -- Pin a port via `cli.port` / `cli.portRange` on the devframe definition, or via `devMiddleware.port` on `viteDevBridge`. +- Pin a port via `cli.port` / `cli.portRange` on the devframe definition, or via `port` on `devframeViteBridge`. - Inspect the `reason` (or the attached `cause`) for the underlying error — fix the setup function or free the port. - For Nuxt: pass `devMiddleware: { port: }` to the `@devframes/nuxt` module. ## Source -- [`packages/devframe/src/helpers/vite.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/helpers/vite.ts) — `viteDevBridge({ devMiddleware })` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`. +- [`packages/vite/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/vite/src/index.ts) — `devframeViteBridge()` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`. diff --git a/docs/errors/DF0052.md b/docs/errors/DF0052.md index 16b19f85..88f34352 100644 --- a/docs/errors/DF0052.md +++ b/docs/errors/DF0052.md @@ -21,7 +21,7 @@ The instance's side-car / shared-server transport binding tried to bind the HTTP ## Fix -- Free the port, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `devMiddleware.port` on `viteDevBridge`. +- Free the port, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `port` on `devframeViteBridge` (`@devframes/vite`). - The original node error is available as `error.cause` — check `error.cause.code` (e.g. `'EADDRINUSE'`) to branch on the failure kind programmatically. ## Source diff --git a/docs/frameworks/index.md b/docs/frameworks/index.md new file mode 100644 index 00000000..65ea37b9 --- /dev/null +++ b/docs/frameworks/index.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# Frameworks + +The framework packages — [`@devframes/vite`](./vite), [`@devframes/nuxt`](./nuxt), and [`@devframes/next`](./next) — integrate devframe with a specific meta-framework's dev server. Each one splits into **two clearly-scoped subpaths**, because you're always doing one of two distinct jobs: + +| Scope | Subpath | You are… | +|-------|---------|----------| +| **dev-spa** | `.../dev-spa` | building & dev-serving a **single devframe's SPA** with that tool | +| **hub** | `.../hub` | mounting a whole **[devframes-hub](/guide/hub)** (many integrations) inside that tool | + +The bare package root (`@devframes/vite`, `@devframes/nuxt`, `@devframes/next`) has no export — it throws with a pointer to the two subpaths, so an accidental bare import fails loudly instead of resolving to nothing. + +| Package | dev-spa | hub | +|---------|---------|-----| +| [`@devframes/vite`](./vite) | `devframeVitePlugin` / `devframeViteBridge` / `devframeVite` | `viteDevframeHub` (+ `/hub/client`) | +| [`@devframes/nuxt`](./nuxt) | the Nuxt module (`modules: ['@devframes/nuxt/dev-spa']`) | the hub Nuxt module (+ `/hub/client`) | +| [`@devframes/next`](./next) | `withDevframe` + `createDevframeNextHandler` (+ `/dev-spa/client`) | `nextDevframeHub` (+ `/hub/client`) | + +## dev-spa: author one devframe + +The `dev-spa` scope is for when the thing you're building **is** a devframe — you author its UI with Vite/Nuxt/Next and want its RPC backend running during development. See each package's page for the details; for the framework-neutral CLI/build/embedded outputs, reach for the [adapters](/adapters/) instead. + +## hub: mount a devframes-hub + +The `hub` scope mounts an [`@devframes/hub`](/guide/hub) — many integrations under one namespace, one merged RPC registry — inside the tool's dev server. Each `hub` entry wraps [`initHub`](/guide/hub-initiate), defaults the UI slot to [`@devframes/hub-ui`](/guide/build-your-own-hub-ui)'s `createUi()` (override with `ui`, or `ui: false` for a headless hub you drive with the matching `/hub/client` helper), and mounts everything behind one catch-all. + +- **[Vite](./vite#mounting-a-hub)** — `viteDevframeHub()` shares Vite's dev server and injects the floating dock. +- **[Nuxt](./nuxt#mounting-a-hub)** — the hub Nuxt module wires the Vite hub plugin into `nuxt dev`. +- **[Next](./next#mounting-a-hub)** — `nextDevframeHub()` serves the hub from one App Router route on a side-car socket. + +Vite and Nuxt already have native hub viewers ([Vite DevTools](https://devtools.vite.dev), [Nuxt DevTools](https://devtools.nuxt.com)) that integrate the same hub protocol, so `@devframes/vite/hub` and `@devframes/nuxt/hub` print a one-time recommendation to prefer those (silence with `{ quiet: true }`). Next has no native counterpart, so `@devframes/next/hub` stays quiet. diff --git a/docs/helpers/next.md b/docs/frameworks/next.md similarity index 64% rename from docs/helpers/next.md rename to docs/frameworks/next.md index 1df9a67b..445f158c 100644 --- a/docs/helpers/next.md +++ b/docs/frameworks/next.md @@ -2,25 +2,26 @@ outline: deep --- -# Next Helper +# Next > [!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. +`@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](./vite): 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: +`@devframes/next` splits into two scopes: `@devframes/next/dev-spa` (author one devframe with Next) and [`@devframes/next/hub`](#mounting-a-hub) (mount a whole devframes-hub). The bare `@devframes/next` import throws with a pointer to both. + +The `dev-spa` scope comes in two 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`. +Plus a React client surface at `@devframes/next/dev-spa/client`. ## Config ```ts [next.config.mjs] -import { withDevframe } from '@devframes/next' +import { withDevframe } from '@devframes/next/dev-spa' export default withDevframe({ // ...your own Next config @@ -34,7 +35,7 @@ export default withDevframe({ `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 { createDevframeNextHandler } from '@devframes/next/dev-spa' import myDevframe from '@/devframe' export const runtime = 'nodejs' @@ -87,11 +88,11 @@ 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. Children render immediately, so your shell and a connection indicator stay visible while the client connects. +`@devframes/next/dev-spa/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' +import { RpcProvider } from '@devframes/next/dev-spa/client' export function Providers({ children }: { children: React.ReactNode }) { return {children} @@ -102,7 +103,7 @@ export function Providers({ children }: { children: React.ReactNode }) { ```tsx [app/panel.tsx] 'use client' -import { useRpc, useRpcStatus } from '@devframes/next/client' +import { useRpc, useRpcStatus } from '@devframes/next/dev-spa/client' export function Panel() { const rpc = useRpc()?.scope('my-tool:') @@ -119,8 +120,26 @@ Both hooks throw outside a ``. Theming and layout stay app-owned. 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. +## Mounting a hub + +`@devframes/next/hub` mounts a whole [devframes-hub](/guide/hub) — many integrations under one namespace — from a single catch-all route. `nextDevframeHub()` returns a route handle memoized on `globalThis` (so Next's dev-time route re-evaluation reuses one instance); `createNextDevframeHub()` is the underlying builder. The UI defaults to `@devframes/hub-ui` (loaded through a bundler-ignored dynamic `import()` so its asset lookups resolve at request time); pass `ui` to swap it or `ui: false` for a headless hub you drive with the React client at `@devframes/next/hub/client` (`useDevframeHubClient()`). + +```ts [app/__devframes/[[...path]]/route.ts] +import { nextDevframeHub } from '@devframes/next/hub' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const hub = nextDevframeHub({ devframes: [] }) +export const GET = (req: Request) => hub.handler(req) +export const POST = (req: Request) => hub.handler(req) +export const DELETE = (req: Request) => hub.handler(req) +``` + +Unlike Vite and Nuxt, Next has no native hub viewer, so this scope prints no recommendation. `createDevframeNextHost()` remains available from `@devframes/next/hub` as the lower-level "bring your own `DevframeHost`" seam for `initHub({ context })`. + ## See also -- [Vite Bridge](./vite-bridge) — the equivalent for Vite-based hosts +- [Vite](./vite) — the equivalent for Vite-based hosts - [Hub](/guide/hub) — `initHub`, `ctx.install`, and `DevframeHost` - [hub-next](/examples/hub-next) — a full working host diff --git a/docs/helpers/nuxt.md b/docs/frameworks/nuxt.md similarity index 78% rename from docs/helpers/nuxt.md rename to docs/frameworks/nuxt.md index 6fac8504..1cd21e27 100644 --- a/docs/helpers/nuxt.md +++ b/docs/frameworks/nuxt.md @@ -2,9 +2,11 @@ outline: deep --- -# Nuxt Helper +# Nuxt -The `@devframes/nuxt` module wires a Nuxt-built SPA as a devframe client, and optionally serves the dev-time RPC bridge alongside `nuxt dev`. It runs inside the Nuxt app that consumes your devframe. +The `@devframes/nuxt/dev-spa` module wires a Nuxt-built SPA as a devframe client, and optionally serves the dev-time RPC bridge alongside `nuxt dev`. It runs inside the Nuxt app that consumes your devframe. + +`@devframes/nuxt` splits into two scopes: `@devframes/nuxt/dev-spa` (this page — author one devframe with Nuxt) and [`@devframes/nuxt/hub`](#mounting-a-hub) (mount a whole devframes-hub). The bare `@devframes/nuxt` import throws with a pointer to both. It handles the four things every Nuxt-powered standalone devtool needs: @@ -17,7 +19,7 @@ It handles the four things every Nuxt-powered standalone devtool needs: ```ts [nuxt.config.ts] export default defineNuxtConfig({ - modules: ['@devframes/nuxt'], + modules: ['@devframes/nuxt/dev-spa'], }) ``` @@ -45,7 +47,7 @@ export function usePayload() { ```ts [nuxt.config.ts] export default defineNuxtConfig({ - modules: ['@devframes/nuxt'], + modules: ['@devframes/nuxt/dev-spa'], devframe: { baseURL: './', // where the devframe snapshot lives, relative to the page skipAppDefaults: false, // opt out of the app.baseURL / vite.base defaults @@ -64,7 +66,7 @@ Pass your devframe definition to wire `nuxt dev` up to the RPC backend: import devframe from './src/devframe' // defineDevframe(...) export export default defineNuxtConfig({ - modules: [['@devframes/nuxt', { devframe }]], + modules: [['@devframes/nuxt/dev-spa', { devframe }]], }) ``` @@ -81,7 +83,7 @@ The bridge is **on by default** whenever `devframe` is set. Skip it (back to cli ```ts [nuxt.config.ts] export default defineNuxtConfig({ - modules: [['@devframes/nuxt', { + modules: [['@devframes/nuxt/dev-spa', { devframe, devMiddleware: { port: 7777, @@ -128,6 +130,18 @@ At build time the module: At runtime the built SPA fetches `./__connection.json` (resolved against `document.baseURI`) and branches on the `backend` field — `websocket` in dev, `static` from a `createBuild` snapshot. +## Mounting a hub + +`@devframes/nuxt/hub` mounts a whole [devframes-hub](/guide/hub) — many integrations under one namespace — alongside `nuxt dev`, wiring `@devframes/vite`'s hub plugin into Nuxt's Vite dev server and injecting `@devframes/hub-ui`'s floating dock. The UI defaults to `@devframes/hub-ui`; pass `ui` to swap it or `ui: false` for a headless hub you drive with `@devframes/nuxt/hub/client`. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: [['@devframes/nuxt/hub', { devframes: [] }]], +}) +``` + +Nuxt DevTools (`@nuxt/devtools`) integrates the same hub protocol natively and is the recommended path for a Nuxt app, so this module prints a one-time recommendation to that effect (silence it with `{ quiet: true }`). + ## See also - [Standalone CLI recipe](/guide/standalone-cli) — end-to-end walk-through diff --git a/docs/frameworks/vite.md b/docs/frameworks/vite.md new file mode 100644 index 00000000..a346beb3 --- /dev/null +++ b/docs/frameworks/vite.md @@ -0,0 +1,69 @@ +--- +outline: deep +--- + +# Vite + +`@devframes/vite` splits into two scopes: **`@devframes/vite/dev-spa`** (this page — dev-serve one devframe's SPA with Vite) and [**`@devframes/vite/hub`**](#mounting-a-hub) (mount a whole devframes-hub inside a Vite app). The bare `@devframes/vite` import throws with a pointer to both. + +The `dev-spa` scope exports two Vite plugins for mounting a single devframe inside an existing Vite dev server — `devframeVitePlugin` (static mount) and `devframeViteBridge` (RPC bridge) — plus `devframeVite`, a convenience wrapper that picks between them. Used by [`@devframes/nuxt`](./nuxt) and available for any Vite-based host (Astro, SolidStart, plain Vite apps). + +This sits below the [`vite` adapter](/adapters/vite) on the abstraction ladder: the adapter targets the full Vite DevTools dock; these are the lower-level Vite plugins you reach for when you want a devframe to ride along with an existing app's dev server without the DevTools dock. + +```ts +import { devframeViteBridge, devframeVitePlugin } from '@devframes/vite/dev-spa' +import { defineConfig } from 'vite' +import devframe from './devframe' + +export default defineConfig({ + // Statically mounts the built SPA at `/__/` — no RPC server: + plugins: [devframeVitePlugin(devframe)], + // Or bridge the RPC/WS backend into this dev server instead — the + // host app owns the SPA: + // plugins: [devframeViteBridge(devframe)], +}) +``` + +## `devframeVitePlugin` — static mount + +Mounts `def.cli.distDir` at `options.base` (`/__/` by default) with SPA fallback. No RPC server is started — useful when you only need the SPA bundle served from a known path. + +| Option | Default | Description | +|--------|---------|-------------| +| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | + +## `devframeViteBridge` — RPC bridge + +Skips the static mount — the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port unless it can share Vite's own HTTP server, so the descriptor carries that port alongside the `/__ws` route. + +To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass Vite's HTTP server to [`initDevframe`](/adapters/initiate) / `initHub` via the `server` option. Devframe binds only its own `__ws` upgrade route and leaves the rest (Vite's HMR socket included) untouched. + +| Option | Default | Description | +|--------|---------|-------------| +| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | +| `port` | share Vite's HTTP server | Pin a side-car port for the RPC socket instead. | +| `host` | `def.cli?.host ?? 'localhost'` | Bind host for a pinned side-car. | +| `flags` | — | Forwarded to `def.setup(ctx, { flags })`. | +| `auth` | gated (interactive OTP) | `false` to opt out for a single-user localhost host, or a `DevframeAuthHandler` for a custom scheme. | +| `mcp` | `def.cli?.mcp` | `true` or `McpRouteOptions` to expose the route-based MCP server at `__mcp`. | + +`port` / `host` / `flags` mirror [`createDevServer`](/adapters/dev)'s options of the same name. + +## `devframeVite` — convenience wrapper + +`devframeVite(def, { bridge, ...bridgeOptions })` forwards to `devframeViteBridge` when `bridge: true`, or `devframeVitePlugin` otherwise — handy when a single call site needs to switch between the two modes. Reach for the two plugins directly when a devframe needs both mounted at once (e.g. a bridge for RPC alongside a static mount serving its own bundled UI, as the built-in `terminals`/`code-server` plugins do). + +## Mounting a hub + +`@devframes/vite/hub` mounts a whole [devframes-hub](/guide/hub) — many integrations under one namespace, one merged RPC registry — inside a Vite dev server with one `viteDevframeHub()` plugin. It wraps `initHub`, shares Vite's HTTP server for the WebSocket, defaults the dock UI to `@devframes/hub-ui` (injecting its `embedded.js` bootstrap into the host page), and mounts everything as connect middleware. + +```ts +import { viteDevframeHub } from '@devframes/vite/hub' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [viteDevframeHub({ devframes: [] })], +}) +``` + +Pass `ui` to swap the viewer or `ui: false` for a headless hub you drive with the client helper at `@devframes/vite/hub/client` (`mountDevframeHubClient()`). Vite DevTools (`@vitejs/devtools-kit`) integrates the same hub protocol natively and is the recommended path for a Vite app, so this plugin prints a one-time recommendation to that effect (silence it with `{ quiet: true }`). diff --git a/docs/guide/migration-0.9.md b/docs/guide/migration-0.9.md index c94c3852..8de11ec4 100644 --- a/docs/guide/migration-0.9.md +++ b/docs/guide/migration-0.9.md @@ -31,6 +31,18 @@ Every entry below has a drop-in replacement. At a glance: | `mountDevframe` | `ctx.install` | | `DEFAULT_CATEGORIES_ORDER` re-exports | `@devframes/hub/constants` | +**Framework adapters (`@devframes/vite` / `@devframes/nuxt` / `@devframes/next`)** + +Each splits into two scoped subpaths — `.../dev-spa` (author one devframe's SPA) and `.../hub` (mount a whole `@devframes/hub`) — and the bare package root throws with a pointer to both. + +| Removed / moved | Replacement | +|---|---| +| `devframe/helpers/vite` (`viteDevBridge`) | `@devframes/vite/dev-spa` (`devframeVite` / `devframeVitePlugin` / `devframeViteBridge`) | +| `@devframes/nuxt` (bare module) | `@devframes/nuxt/dev-spa` | +| `@devframes/next` root (`withDevframe`, `createDevframeNextHandler`) | `@devframes/next/dev-spa` | +| `@devframes/next/client` | `@devframes/next/dev-spa/client` | +| mount a hub inside a tool | `@devframes/{vite,nuxt,next}/hub` (+ `/hub/client`) | + ## `devframe/adapters/cli` is removed The CLI adapter was renamed to `cac` in 0.7. The `devframe/adapters/cli` entry - `createCli`, `CreateCliOptions`, and `CliHandle` - is now gone. Import from `devframe/adapters/cac` instead: @@ -235,3 +247,82 @@ await ctx.install(myDevframe) // 0.9 import { DEFAULT_CATEGORIES_ORDER } from '@devframes/hub/constants' ``` + +## The Vite bridge moves to `@devframes/vite` + +`devframe/helpers/vite` is now its own package, `@devframes/vite` — so it can depend on `vite` directly (its plugins are typed against Vite's real `Plugin` / `ViteDevServer`) while `devframe` core stays free of a Vite dependency. It also splits into two scoped subpaths, and the single `viteDevBridge` becomes three purpose-named plugins on `@devframes/vite/dev-spa`: + +| 0.8.x | 0.9 | +|---|---| +| `import { viteDevBridge } from 'devframe/helpers/vite'` | `import { devframeVite } from '@devframes/vite/dev-spa'` | +| `viteDevBridge(def)` (static mount) | `devframeVitePlugin(def)` | +| `viteDevBridge(def, { devMiddleware: true })` (RPC bridge) | `devframeViteBridge(def)` | +| `viteDevBridge(def, { devMiddleware: { port, host, flags } })` | `devframeViteBridge(def, { port, host, flags })` | + +The `devMiddleware` boolean/object option is gone: `devframeVitePlugin` is always the static mount, `devframeViteBridge` is always the RPC bridge, and their bridge options are flattened to the top level (`port`, `host`, `flags`, `auth`, `mcp`). `devframeVite(def, { bridge })` is a convenience wrapper that picks between the two. + +```ts +// 0.8.x +import { viteDevBridge } from 'devframe/helpers/vite' + +export default defineConfig({ + plugins: [viteDevBridge(devframe, { devMiddleware: true })], +}) +``` + +```ts +// 0.9 +import { devframeViteBridge } from '@devframes/vite/dev-spa' + +export default defineConfig({ + plugins: [devframeViteBridge(devframe)], +}) +``` + +`@devframes/vite` (and `@devframes/nuxt` / `@devframes/next`) take `@devframes/hub` and `@devframes/hub-ui` as **optional** peers — only the `/hub` scope needs them. Install `vite` as a peer as before. See [`@devframes/vite`](/frameworks/vite) for the full reference. + +## `@devframes/nuxt` and `@devframes/next` split into `/dev-spa` and `/hub` + +Both packages now serve their single-devframe surface from a `.../dev-spa` subpath, and the bare package root throws with a pointer to the two scopes. + +Nuxt — register the module by its subpath: + +```ts +// 0.8.x [nuxt.config.ts] +export default defineNuxtConfig({ modules: ['@devframes/nuxt'] }) + +// 0.9 [nuxt.config.ts] +export default defineNuxtConfig({ modules: ['@devframes/nuxt/dev-spa'] }) +``` + +Next — the config/handler helpers and the React client move down a level: + +| 0.8.x | 0.9 | +|---|---| +| `import { withDevframe } from '@devframes/next'` | `import { withDevframe } from '@devframes/next/dev-spa'` | +| `import { createDevframeNextHandler } from '@devframes/next'` | `import { createDevframeNextHandler } from '@devframes/next/dev-spa'` | +| `import { RpcProvider, useRpc } from '@devframes/next/client'` | `import { RpcProvider, useRpc } from '@devframes/next/dev-spa/client'` | + +## Mounting a hub: the new `/hub` scope + +Standing up a whole `@devframes/hub` (many integrations) inside a tool now has a first-class home instead of hand-rolled `initHub` glue: `@devframes/vite/hub`, `@devframes/nuxt/hub`, and `@devframes/next/hub`. Each wraps `initHub`, defaults the UI slot to `@devframes/hub-ui`'s `createUi()` (override with `ui`, or `ui: false` for a headless hub you drive with the matching `/hub/client` helper), and mounts everything under one namespace. + +```ts +// Vite +import { viteDevframeHub } from '@devframes/vite/hub' + +export default defineConfig({ plugins: [viteDevframeHub({ devframes: [] })] }) +``` + +```ts +// Next — app/__devframes/[[...path]]/route.ts +import { nextDevframeHub } from '@devframes/next/hub' + +export const runtime = 'nodejs' +const hub = nextDevframeHub({ devframes: [] }) +export const GET = (req: Request) => hub.handler(req) +export const POST = (req: Request) => hub.handler(req) +export const DELETE = (req: Request) => hub.handler(req) +``` + +Vite and Nuxt already have native hub viewers, so `@devframes/vite/hub` and `@devframes/nuxt/hub` print a one-time recommendation to prefer [Vite DevTools](https://devtools.vite.dev) / [Nuxt DevTools](https://devtools.nuxt.com) (silence with `{ quiet: true }`); `@devframes/next/hub` has no native counterpart and stays quiet. See [`@devframes/vite`](/frameworks/vite#mounting-a-hub), [`@devframes/nuxt`](/frameworks/nuxt#mounting-a-hub), and [`@devframes/next`](/frameworks/next#mounting-a-hub). diff --git a/docs/guide/security.md b/docs/guide/security.md index ed098d49..114fa246 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -92,7 +92,7 @@ Higher-level integrations can drive their own authentication UI instead: disable ## Practices for tools built on devframe - **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. -- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. The hosted bridges (`viteDevBridge`, `@devframes/next`'s handler) gate their side-car by default too — a host that owns the trust boundary another way opts out with `auth: false` explicitly. +- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default too — a host that owns the trust boundary another way opts out with `auth: false` explicitly. - **The MCP route requires an origin.** Unlike the WS transport, the route-based MCP server rejects `Origin`-less requests (a request must carry a loopback or allow-listed `Origin`), so a route-based endpoint isn't reachable by an arbitrary local process — see [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output. - **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md index 9968a211..7404adfb 100644 --- a/docs/guide/standalone-cli.md +++ b/docs/guide/standalone-cli.md @@ -90,7 +90,7 @@ For the Nuxt side, add the devframe helper module — it sets `app.baseURL: './' ```ts [nuxt.config.ts] export default defineNuxtConfig({ ssr: false, - modules: ['@devframes/nuxt'], + modules: ['@devframes/nuxt/dev-spa'], nitro: { preset: 'static', output: { dir: './dist' }, // matches createCac's distDir of ./dist/public @@ -98,7 +98,7 @@ export default defineNuxtConfig({ }) ``` -Build with `nuxt build` and point `cli.distDir` at `./dist/public`. The SPA discovers its effective base at runtime — no `--base` rewrite needed. See the [Nuxt helper docs](/helpers/nuxt) for the full reference. +Build with `nuxt build` and point `cli.distDir` at `./dist/public`. The SPA discovers its effective base at runtime — no `--base` rewrite needed. See the [Nuxt docs](/frameworks/nuxt) for the full reference. ## Next.js SPA setup diff --git a/docs/helpers/index.md b/docs/helpers/index.md index 90a967b0..689a750f 100644 --- a/docs/helpers/index.md +++ b/docs/helpers/index.md @@ -4,15 +4,14 @@ outline: deep # Helpers -Helpers are the optional, opt-in surface around the core `defineDevframe` API: small wrappers for runtime integration, prebuilt RPC recipes, and a curated set of low-level utilities. None of them are required to ship a devframe — reach for them when they match the shape of what you're building. +Helpers are the optional, opt-in surface around the core `defineDevframe` API: prebuilt RPC recipes and a curated set of low-level utilities, all served from the `devframe` package itself. None of them are required to ship a devframe — reach for them when they match the shape of what you're building. | Helper | Entry | What it does | |--------|-------|--------------| | [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). | | [Common RPC Functions](./common-rpc-functions) | `devframe/recipes/common-rpc-functions` | 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. | -Helpers vs. [adapters](/adapters/): an adapter takes a `DevframeDefinition` and deploys it as a runnable surface (CLI, dev server, static build, MCP server). A helper is a smaller piece — a Vite plugin, a Nuxt module, a recipe, a utility function — that you compose alongside an adapter. +Helpers vs. [adapters](/adapters/): an adapter takes a `DevframeDefinition` and deploys it as a runnable surface (CLI, dev server, static build, MCP server). A helper is a smaller piece — a recipe or a utility function — that you compose alongside an adapter. + +For integrating a devframe (or a whole hub) with a specific meta-framework's dev server, see the dedicated [`@devframes/*` framework packages](/frameworks/) instead. diff --git a/docs/helpers/vite-bridge.md b/docs/helpers/vite-bridge.md deleted file mode 100644 index 87b0aae5..00000000 --- a/docs/helpers/vite-bridge.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -outline: deep ---- - -# Vite Bridge - -A thin Vite plugin for mounting a devframe inside an existing Vite dev server. Used by [`@devframes/nuxt`](./nuxt) and available for any Vite-based host (Astro, SolidStart, plain Vite apps). - -This sits below the [`vite` adapter](/adapters/vite) on the abstraction ladder: the adapter targets the full Vite DevTools dock; the bridge is the lower-level Vite plugin you reach for when you want a devframe to ride along with an existing app's dev server without the DevTools dock. - -```ts -import { viteDevBridge } from 'devframe/helpers/vite' -import { defineConfig } from 'vite' -import devframe from './devframe' - -export default defineConfig({ - plugins: [viteDevBridge(devframe)], -}) -``` - -## Modes - -- **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__/` by default). No RPC server. Useful when you only need the SPA bundle served from a known path. -- **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port, so the descriptor carries that port alongside the `/__ws` route. - -To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass Vite's HTTP server to [`initDevframe`](/adapters/initiate) / `initHub` via the `server` option. Devframe binds only its own `__ws` upgrade route and leaves the rest (Vite's HMR socket included) untouched. - -## Options - -| Option | Default | Description | -|--------|---------|-------------| -| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | -| `devMiddleware` | `false` | `true` or `{ port?, host?, flags? }` to enable bridge mode. | - -When `devMiddleware` is an object, the inner fields mirror [`createDevServer`](/adapters/dev) — `port` pins the WS server port, `host` sets the bind host, and `flags` is forwarded to `def.setup(ctx, { flags })`. diff --git a/examples/hub-next-minimal/src/client/hub.ts b/examples/hub-next-minimal/src/client/hub.ts index 34683315..4c33e5f8 100644 --- a/examples/hub-next-minimal/src/client/hub.ts +++ b/examples/hub-next-minimal/src/client/hub.ts @@ -4,7 +4,7 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { jsonRenderUiRenderer as JsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import type { DevframeDefinition } from 'devframe' -import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' +import { createNextDevframeHub } from '@devframes/next/hub' // A server-authored JSON-render dock: the whole view is this serializable // spec — no client build. It renders through whatever `'json-render'` @@ -70,16 +70,14 @@ async function loadHub(): Promise { (dataInspector.createDataInspectorDevframe as (options: { id: string }) => DevframeDefinition)({ id: 'devframes_plugin_data-inspector' }), (assets.createAssetsDevframe as (options: { watch: boolean }) => DevframeDefinition)({ watch: false }), ] - // Next route handlers can't accept WebSocket upgrades, so the socket asks - // for a side-car server of its own, advertised via `__connection.json`. - return initHub({ - base: DEVFRAMES_HUB_BASE, - ws: { sidecar: true }, + // `@devframes/next/hub` runs the socket on a side-car (Next routes can't + // accept WS upgrades). This host overrides the default UI slot to rebrand + // the reference viewer to Next.js/Vercel's monochrome black — one field, no + // CSS: `createUi`'s `branding` option publishes `branding.json`, which the + // dock fetches at boot and feeds into `--devframe-primary` (see + // `@devframes/hub-ui`'s `primary-ramp.css`). + return createNextDevframeHub({ devframes, - // Rebrand the reference UI to Next.js/Vercel's monochrome black — one - // field, no CSS: `createUi`'s `branding` option publishes - // `branding.json`, which the dock fetches at boot and feeds into - // `--devframe-primary` (see `@devframes/hub-ui`'s `primary-ramp.css`). ui: (hubUi.createUi as typeof CreateUi)({ branding: { primaryColor: '#000000', productName: 'Devframes on Next.js' } }), // Serve the reference json-render frontend as a prebuilt renderer module // — the one-liner that makes `'json-render'` docks render in the prebuilt diff --git a/examples/hub-next-minimal/src/client/next.config.mjs b/examples/hub-next-minimal/src/client/next.config.mjs index 2bb6013c..9a406e4a 100644 --- a/examples/hub-next-minimal/src/client/next.config.mjs +++ b/examples/hub-next-minimal/src/client/next.config.mjs @@ -1,4 +1,4 @@ -import { withDevframe } from '@devframes/next' +import { withDevframe } from '@devframes/next/dev-spa' // `withDevframe` applies the settings a devframe host requires (currently // `skipTrailingSlashRedirect: true`, so mounted SPAs' relative assets under diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts index b488b1b0..b86bc79e 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -6,8 +6,9 @@ import { homedir } from 'node:os' import process from 'node:process' import { fileURLToPath } from 'node:url' import { defineHubRpcFunction } from '@devframes/hub' -import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' +import { DEVFRAMES_HUB_BASE } from '@devframes/hub/initiate' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' +import { createNextDevframeHub } from '@devframes/next/hub' import { createDashboardView } from 'json-render/dashboard' import { dirname, join } from 'pathe' import demoDevframe from './demo-devframe' @@ -162,11 +163,13 @@ const nextHubTerminalsList = defineHubRpcFunction({ }) /** - * The entire Next host: one `initHub()` call. The instance mounts every - * devframe under `/__devframes//`, merges their RPC registries onto one - * WebSocket side-car, serves the discovery endpoints (`__connection.json`, - * `__index.json`, `__client-imports.js`) and the aggregate MCP route - all - * behind the one web-standard `handler` the App Router catch-all delegates to. + * The entire Next host, built on `@devframes/next/hub`'s + * {@link createNextDevframeHub}: one call mounts every devframe under + * `/__devframes//`, merges their RPC registries onto one WebSocket + * side-car, serves the discovery endpoints and the aggregate MCP route behind + * the one web-standard `handler` the App Router catch-all delegates to. This + * host renders its own React UI (`app/page.tsx`), so it opts out of the + * default `@devframes/hub-ui` slot with `ui: false`. */ export async function nextDevframeHub( options: NextDevframeHubOptions = {}, @@ -211,7 +214,7 @@ export async function nextDevframeHub( }, ] - const hub = initHub({ + const hub = await createNextDevframeHub({ base: DEVFRAMES_HUB_BASE, cwd, origin, @@ -224,9 +227,12 @@ export async function nextDevframeHub( // surface (agent-flagged commands, plugin tools, `devframe:state:read`) // over the same catch-all route as the SPAs. mcp: true, - // Next route handlers can't accept WS upgrades, so the socket asks for a - // side-car of its own - on a free port near 9777, or the pinned `port`. - ws: options.port != null ? { port: options.port } : { sidecar: true }, + // This host renders its own React UI in `app/page.tsx`, so skip the + // default `@devframes/hub-ui` viewer/embedded slot. + ui: false, + // Next route handlers can't accept WS upgrades — `createNextDevframeHub` + // runs the socket on a side-car (a free port near 9777, or the pinned one). + port: options.port, getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') diff --git a/examples/hub-next/src/client/next.config.mjs b/examples/hub-next/src/client/next.config.mjs index 32fd3ef4..c6605272 100644 --- a/examples/hub-next/src/client/next.config.mjs +++ b/examples/hub-next/src/client/next.config.mjs @@ -1,4 +1,4 @@ -import { withDevframe } from '@devframes/next' +import { withDevframe } from '@devframes/next/dev-spa' // `withDevframe` applies the settings a devframe host requires (currently // `skipTrailingSlashRedirect: true`, so mounted SPAs' relative assets under diff --git a/examples/hub-vite-minimal/package.json b/examples/hub-vite-minimal/package.json index 85814f6f..1ca872bf 100644 --- a/examples/hub-vite-minimal/package.json +++ b/examples/hub-vite-minimal/package.json @@ -23,6 +23,7 @@ "@devframes/plugin-messages": "workspace:*", "@devframes/plugin-og": "workspace:*", "@devframes/plugin-terminals": "workspace:*", + "@devframes/vite": "workspace:*", "devframe": "workspace:*" }, "devDependencies": { diff --git a/examples/hub-vite-minimal/vite.config.ts b/examples/hub-vite-minimal/vite.config.ts index 73d1f8bb..2f7735ed 100644 --- a/examples/hub-vite-minimal/vite.config.ts +++ b/examples/hub-vite-minimal/vite.config.ts @@ -1,8 +1,6 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' -import { Server as NodeHttpServer } from 'node:http' import { createUi } from '@devframes/hub-ui' -import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { createA11yDevframe } from '@devframes/plugin-a11y' import { createAssetsDevframe } from '@devframes/plugin-assets' @@ -13,6 +11,7 @@ import { createInspectDevframe } from '@devframes/plugin-inspect' import { createMessagesDevframe } from '@devframes/plugin-messages' import { createOgDevframe } from '@devframes/plugin-og' import { createTerminalsDevframe } from '@devframes/plugin-terminals' +import { viteDevframeHub } from '@devframes/vite/hub' import { defineConfig } from 'vite' // Every built-in plugin, dogfooded end to end through the hub mount path. @@ -31,10 +30,6 @@ const builtinDevframes = [ createAssetsDevframe({ watch: false }), ] -// The one mount base, referenced by both `initHub({ base })` and the injected -// embedded-script URL - no duplicated string literal. -const base = DEVFRAMES_HUB_BASE - // A server-authored JSON-render dock: the whole view is this serializable // spec — no client build. It renders through whatever `'json-render'` // renderer the hub composes (below, the reference `@devframes/json-render-ui` @@ -57,62 +52,38 @@ const jsonRenderDock: DevframeJsonRenderDockEntry = { view: { spec: jsonRenderSpec }, } -// The minimal Vite host: one `initHub()` call mounted as connect middleware -// on the Vite dev server, created inside `configureServer` so importing this -// config never boots a hub. `initHub` runs here in Vite's Node config process -// (never bundled into the browser), so `createUi()`'s prebuilt-asset lookup -// and the plugins' node code work unchanged. The WebSocket upgrade shares -// Vite's own dev server at `/__devframes/__ws` (zero extra ports); the dock -// UI comes from `@devframes/hub-ui` via the `ui` slot. +// The minimal Vite host: one `viteDevframeHub()` plugin from +// `@devframes/vite/hub`. It wraps `initHub` (mounted as connect middleware on +// Vite's dev server, sharing its HTTP server for the WS upgrade at +// `/__devframes/__ws`) and injects the UI's `embedded.js` bootstrap into the +// host page — the whole embedded integration in one call. `quiet` silences the +// Vite-DevTools recommendation for this reference example. export default defineConfig({ // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels): accept // any Host header and fall back to the next free port when busy. server: { allowedHosts: true, strictPort: false }, - plugins: [{ - name: 'hub-vite-minimal', - apply: 'serve', - configureServer(server) { - // Share Vite's own HTTP server for the WS upgrade at - // `/__devframes/__ws` - zero extra ports. Only a plain-HTTP dev server - // qualifies (an https/http2 one isn't a `node:http` server), so an - // auto-port side-car covers the rest; either way the browser finds the - // socket through `__connection.json`. - const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined - const hub = initHub({ - base, - devframes: builtinDevframes, - // Rebrand the reference UI to Vite's own purple — one field, no CSS: - // `createUi`'s `branding` option publishes `branding.json`, which the - // dock fetches at boot and feeds into `--devframe-primary` (see - // `@devframes/hub-ui`'s `primary-ramp.css`). - ui: createUi({ branding: { primaryColor: '#646cff', productName: 'Devframes on Vite' } }), - // Serve the reference json-render frontend as a prebuilt renderer - // module — the one-liner that makes `'json-render'` docks render in - // the prebuilt viewer. Swap it for any community implementation of - // the same contract. - renderers: [jsonRenderUiRenderer()], - configure(ctx) { - ctx.docks.register(jsonRenderDock) - }, - // Gate with devframe's interactive OTP (the default). The hub prints a - // 6-digit code + magic link on startup, and the reference UI's - // authorization view exchanges it for a bearer token. See - // docs/guide/security.md. - server: httpServer, - ...(httpServer ? {} : { ws: { sidecar: true } }), - }) - // Self-filters by base and calls next() otherwise, so Vite keeps serving - // the host page and its assets while the hub owns `/__devframes/*`. - server.middlewares.use(hub.nodeMiddleware) - }, - // Inject the floating-dock bootstrap into the host page - one dev-only - // module script, the whole embedded integration. - transformIndexHtml() { - return [{ - tag: 'script', - attrs: { type: 'module', src: `${base}embedded.js` }, - injectTo: 'body', - }] - }, - }], + plugins: [ + viteDevframeHub({ + quiet: true, + devframes: builtinDevframes, + // Rebrand the reference UI to Vite's own purple — one field, no CSS: + // `createUi`'s `branding` option publishes `branding.json`, which the + // dock fetches at boot and feeds into `--devframe-primary` (see + // `@devframes/hub-ui`'s `primary-ramp.css`). Passing `ui` overrides the + // default `createUi()` the plugin would otherwise use. + ui: createUi({ branding: { primaryColor: '#646cff', productName: 'Devframes on Vite' } }), + // Serve the reference json-render frontend as a prebuilt renderer + // module — the one-liner that makes `'json-render'` docks render in + // the prebuilt viewer. Swap it for any community implementation of + // the same contract. + renderers: [jsonRenderUiRenderer()], + configure(ctx) { + ctx.docks.register(jsonRenderDock) + }, + // Gate with devframe's interactive OTP (the default): the hub prints a + // 6-digit code + magic link on startup, and the reference UI's + // authorization view exchanges it for a bearer token. See + // docs/guide/security.md. + }), + ], }) diff --git a/examples/hub-vite/package.json b/examples/hub-vite/package.json index c5a8140f..be8fe25b 100644 --- a/examples/hub-vite/package.json +++ b/examples/hub-vite/package.json @@ -24,6 +24,7 @@ "@devframes/plugin-messages": "workspace:*", "@devframes/plugin-og": "workspace:*", "@devframes/plugin-terminals": "workspace:*", + "@devframes/vite": "workspace:*", "colorjs.io": "catalog:frontend", "devframe": "workspace:*", "dompurify": "catalog:frontend", @@ -31,7 +32,6 @@ }, "devDependencies": { "@iconify-json/ph": "catalog:frontend", - "pathe": "catalog:deps", "unocss": "catalog:frontend", "vite": "catalog:build" } diff --git a/examples/hub-vite/src/vite-devframe-hub.ts b/examples/hub-vite/src/vite-devframe-hub.ts deleted file mode 100644 index 41587163..00000000 --- a/examples/hub-vite/src/vite-devframe-hub.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { DockRendererRegistration, HubDevframeEntry, HubInstance } from '@devframes/hub/initiate' -import type { DevframeHubContext } from '@devframes/hub/node' -import type { ClientScriptEntry } from '@devframes/hub/types' -import type { DevframeDefinition } from 'devframe' -import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' -import { Server as NodeHttpServer } from 'node:http' -import { homedir } from 'node:os' -import { defineHubRpcFunction } from '@devframes/hub' -import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' -import { join } from 'pathe' - -export interface ViteDevframeHubOptions { - /** - * Mount base the hub answers under - every frame lives at `/`. - * Default: `/__devframes/`. - */ - base?: string - /** - * Pin a side-car port for the RPC/WS server. By default the WebSocket - * shares Vite's own http server, upgrading at `__ws`. - */ - port?: number - /** - * Devframes to mount as docks. Wrap an entry in `{ devframe, dock }` to - * customize its synthesized iframe dock (category, `frameId`, `subTabs`, …). - */ - devframes?: (DevframeDefinition | HubDevframeEntry)[] - /** - * Per-dock client scripts, keyed by devframe id. Attached to the mounted - * iframe dock so the hub client runtime imports them into the host page - * (e.g. the a11y inspector's in-page agent). - */ - clientScripts?: Record - /** - * Prebuilt dock-renderer modules forwarded to `initHub({ renderers })` — - * each is served at `__renderers/.mjs` and published in the - * renderer manifest, so the client imports it lazily the first time a dock - * of its type mounts (e.g. `jsonRenderUiRenderer()` from - * `@devframes/json-render-ui/hub` for `json-render` docks). - */ - renderers?: DockRendererRegistration[] - /** - * Called once the hub context is created (after devframes are mounted), - * inside `initHub`'s `configure` step. Lets the composition register extra - * surfaces on the context - e.g. a `json-render` dock via - * `@devframes/json-render`. - */ - onContextReady?: (context: DevframeHubContext) => void | Promise -} - -// Minimal hub-local RPCs - used by the UI for read-side data. A more -// ambitious hub host might hoist these into `@devframes/hub` itself. -const viteHubMessagesList = defineHubRpcFunction({ - name: 'example:vite-devframe-hub:messages:list', - type: 'static', - jsonSerializable: true, - setup: (ctx: DevframeHubContext) => ({ - async handler() { - return Array.from(ctx.messages.entries.values()) - }, - }), -}) - -const viteHubTerminalsList = defineHubRpcFunction({ - name: 'example:vite-devframe-hub:terminals:list', - type: 'static', - jsonSerializable: true, - setup: (ctx: DevframeHubContext) => ({ - async handler() { - return Array.from(ctx.terminals.sessions.values()).map(s => ({ - id: s.id, - title: s.title, - description: s.description, - status: s.status, - })) - }, - }), -}) - -/** - * A deliberately tiny Vite plugin that wires `@devframes/hub` into a Vite - * dev server: one `initHub()` call assembles the whole hub - every devframe - * mounted under `/`, one merged RPC registry on one WebSocket - * (upgrading on Vite's own server at `__ws`), and the discovery - * endpoints (`__connection.json`, `__index.json`, `__client-imports.js`) - - * behind one connect-style middleware that self-filters by the base. - * - * This file is the entire Vite host - every other framework's hub host is - * the same shape: a thin layer that adapts a framework's dev server to the hub. - */ -export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { - const base = normalizeBase(options.base ?? DEVFRAMES_HUB_BASE) - let viteConfig: ResolvedConfig | undefined - let instance: HubInstance | undefined - - // Every teardown path funnels here: close the hub (WS binding / side-car, - // its instance-registry record, and mounted frames' resources). - const teardown = async (): Promise => { - const previous = instance - instance = undefined - await previous?.close().catch(() => {}) - } - - return { - name: 'vite-devframe-hub', - apply: 'serve', - - configResolved(config) { - viteConfig = config - }, - - async configureServer(server: ViteDevServer) { - // Vite re-invokes `configureServer` on each restart. Tear down the - // previous instance so we don't leak the WS binding or leave a ghost - // registry record behind. - await teardown() - - const cwd = viteConfig!.root - - // Attach each configured client script to its devframe's mount entry, - // so the hub client runtime imports it into the host page. - const devframes = (options.devframes ?? []).map((entry) => { - const def = 'devframe' in entry ? entry.devframe : entry - const clientScript = options.clientScripts?.[def.id] - if (!clientScript) - return entry - return 'devframe' in entry - ? { ...entry, dock: { clientScript, ...entry.dock } } - : { devframe: def, dock: { clientScript } } - }) - - const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined - - const hub = initHub({ - base, - cwd, - // Resolved lazily - Vite knows its local URL only once listening; an - // empty string until then defers both the auth banner and the registry - // record to the first request, whose origin is the real dialable one. - origin: () => { - const resolved = server.resolvedUrls?.local?.[0] - return resolved ? new URL(resolved).origin : '' - }, - // Gate access with devframe's interactive OTP (the default): the hub - // prints a 6-digit code + magic link once its origin resolves, and the - // client shell (`src/client/main.ts`) drives its own authorization view - // to exchange the code for a bearer token. See `docs/guide/security.md`. - // Share Vite's own http server for the WebSocket upgrade at - // `__ws` - no side-car port to discover. A `port` option pins - // a side-car server instead, and an https/http2 dev server (where - // Vite hands us a non-`node:http` server) asks for an auto-port - // side-car - clients discover either via `__connection.json`. - server: httpServer, - ...(options.port != null - ? { ws: { port: options.port } } - : httpServer - ? {} - : { ws: { sidecar: true } }), - getStorageDir(scope) { - if (scope === 'workspace') - return join(cwd, '.devframe') - if (scope === 'project') - return join(cwd, 'node_modules/.vite-devframe-hub') - return join(homedir(), '.vite-devframe-hub') - }, - // List this hub in the global instance registry (`~/.devframe/instances/`) - // so discovery tooling - `devframe connect`, the inspector's Instances - // tab - sees it like any standalone devframe. The instance owns the - // record: written once the first request resolves the dialable origin, - // removed on close. `rootDir` is the Vite project root. - register: { - id: 'example:vite-devframe-hub', - name: 'Vite Devframe Hub', - rootDir: cwd, - }, - rpcDeclarations: [ - // The minimal hub ships its own `messages:list` and `terminals:list` - // RPCs so the UI has something to read. A full hub kit would - // likely standardise these (alongside the built-in - // `hub:commands:execute`) but for the demo we keep them kit-local. - viteHubMessagesList, - viteHubTerminalsList, - ], - devframes, - ...(options.renderers ? { renderers: options.renderers } : {}), - async configure(ctx) { - // Seed a sample command directly on the hub so the UI - // shows something even without any plugged-in devframes. - ctx.commands.register({ - id: 'example:vite-devframe-hub:ping', - title: 'Vite Hub · Ping', - icon: 'ph:bell-duotone', - category: 'kit', - handler: () => 'pong', - }) - await ctx.messages.add({ - level: 'success', - message: 'Vite Devframe Hub started', - description: options.port != null - ? `Side-car WS on port ${options.port}. ${devframes.length} devframe(s) mounted under ${base}.` - : `WS shared on the Vite server at ${base}__ws. ${devframes.length} devframe(s) mounted under ${base}.`, - }) - - await options.onContextReady?.(ctx) - }, - }) - instance = hub - - // One namespace, one catch-all: the middleware serves everything under - // `base` and `next()`s the rest back to Vite. - server.middlewares.use(hub.nodeMiddleware) - - server.httpServer?.once('close', () => { - if (instance !== hub) - return - void teardown() - }) - }, - - async closeBundle() { - await teardown() - }, - } -} - -function normalizeBase(base: string): string { - let out = base.startsWith('/') ? base : `/${base}` - if (!out.endsWith('/')) - out = `${out}/` - return out -} diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts index ee976804..7900f053 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -1,3 +1,5 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { defineHubRpcFunction } from '@devframes/hub' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' @@ -10,6 +12,7 @@ import inspectDevframe from '@devframes/plugin-inspect' import messagesDevframe from '@devframes/plugin-messages' import ogDevframe from '@devframes/plugin-og' import terminalsDevframe from '@devframes/plugin-terminals' +import { viteDevframeHub } from '@devframes/vite/hub' import { createDashboardView } from 'json-render/dashboard' import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' @@ -17,7 +20,6 @@ import { alias } from '../../alias' import demoDevframe from './src/devframe' import tabbedToolDevframe from './src/tabbed-tool' import { unrenderedDockEntry } from './src/unrendered-dock' -import { viteDevframeHub } from './src/vite-devframe-hub' // Colon-free id override: the hub instance derives each frame's mount path // (`/__devframes//`) from its id, and `:` - which the plugin's default id @@ -25,6 +27,37 @@ import { viteDevframeHub } from './src/vite-devframe-hub' // the router underneath. const dataInspectorDevframe = createDataInspectorDevframe({ id: 'devframes_plugin_data-inspector' }) +// Minimal hub-local RPCs the vanilla client reads for its message / terminal +// lists. A more ambitious host would standardise these (alongside the +// built-in `hub:commands:execute`); the reference keeps them example-local and +// hands them to `viteDevframeHub` via `rpcDeclarations`. +const messagesList = defineHubRpcFunction({ + name: 'example:vite-devframe-hub:messages:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.messages.entries.values()) + }, + }), +}) + +const terminalsList = defineHubRpcFunction({ + name: 'example:vite-devframe-hub:terminals:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.terminals.sessions.values()).map(s => ({ + id: s.id, + title: s.title, + description: s.description, + status: s.status, + })) + }, + }), +}) + export default defineConfig({ resolve: { alias }, // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels, tailnets): @@ -60,7 +93,19 @@ export default defineConfig({ }) }, }, + // The whole Vite host: `@devframes/vite/hub` wraps `initHub` and mounts it + // as connect middleware. This host renders its own vanilla client + // (src/client/main.ts) against `@devframes/hub/client`, so it opts out of + // the default `@devframes/hub-ui` slot with `ui: false`. `quiet` silences + // the Vite-DevTools recommendation for this reference example. viteDevframeHub({ + ui: false, + quiet: true, + register: { + id: 'example:vite-devframe-hub', + name: 'Vite Devframe Hub', + }, + rpcDeclarations: [messagesList, terminalsList], devframes: [ demoDevframe, // Every built-in plugin, dogfooded end-to-end through the hub mount @@ -102,10 +147,20 @@ export default defineConfig({ // `json-render` dock mounts — no Vue and no renderer code compiled // into this host's own bundle. renderers: [jsonRenderUiRenderer()], - // Dogfood the opt-in JSON-render hub integration: author a view on the - // hub context and project it onto a `json-render` dock, rendered by the - // manifest module above. - onContextReady: (context) => { + configure: async (context) => { + // Seed a sample command directly on the hub so the UI shows something + // even without any plugged-in devframes. + context.commands.register({ + id: 'example:vite-devframe-hub:ping', + title: 'Vite Hub · Ping', + icon: 'ph:bell-duotone', + category: 'kit', + handler: () => 'pong', + }) + + // Dogfood the opt-in JSON-render hub integration: author a view on the + // hub context and project it onto a `json-render` dock, rendered by the + // manifest module above. const view = createDashboardView(context) context.docks.register(toJsonRenderDockEntry(view, { id: 'example:json-render', @@ -116,6 +171,12 @@ export default defineConfig({ // Witness the missing-renderer path: a dock type nothing covers — // the client shows its fallback view instead of a dead panel. context.docks.register(unrenderedDockEntry) + + await context.messages.add({ + level: 'success', + message: 'Vite Devframe Hub started', + description: 'Mounted under /__devframes/ with the built-in plugins.', + }) }, }), ], 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 b848a510..2a21f777 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx @@ -2,7 +2,7 @@ import type { DevframeScopedClientContext } from 'devframe/client' import type { ReactNode } from 'react' -import { RpcProvider as DevframeRpcProvider, useRpc as useDevframeRpc, useRpcStatus } from '@devframes/next/client' +import { RpcProvider as DevframeRpcProvider, useRpc as useDevframeRpc, useRpcStatus } from '@devframes/next/dev-spa/client' import { useMemo } from 'react' // Inlined (not imported from the server `rpc/index.ts`) so the client @@ -17,7 +17,7 @@ interface ConnectionState { } /** - * Connect to the RPC backend via `@devframes/next/client` — the connect + + * Connect to the RPC backend via `@devframes/next/dev-spa/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. */ diff --git a/knip.jsonc b/knip.jsonc index c036adf2..8c8e7573 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -90,7 +90,6 @@ // listed explicitly instead. Keep this in sync with the `exports` map. "entry": [ "src/{index,constants}.ts", - "src/helpers/vite.ts", "src/adapters/{build,cac,dev,embedded,initiate}.ts", "src/adapters/mcp/index.ts", "src/client/index.ts", diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 72cc4718..46bd3e7d 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -27,7 +27,6 @@ "./adapters/mcp": "./dist/adapters/mcp.mjs", "./client": "./dist/client/index.mjs", "./constants": "./dist/constants.mjs", - "./helpers/vite": "./dist/helpers/vite.mjs", "./initiate": "./dist/adapters/initiate.mjs", "./internal": "./dist/internal/index.mjs", "./node": "./dist/node/index.mjs", diff --git a/packages/devframe/scripts/check-client-dist.ts b/packages/devframe/scripts/check-client-dist.ts index 61f7bbe7..917f88a9 100644 --- a/packages/devframe/scripts/check-client-dist.ts +++ b/packages/devframe/scripts/check-client-dist.ts @@ -14,7 +14,6 @@ const FORBIDDEN: ForbiddenRule[] = [ { name: 'devframe/rpc/transports/*', match: id => id.startsWith('devframe/rpc/transports/') }, { name: 'devframe/node*', match: id => id === 'devframe/node' || id.startsWith('devframe/node/') }, { name: 'devframe/adapters/*', match: id => id.startsWith('devframe/adapters/') }, - { name: 'devframe/helpers/*', match: id => id.startsWith('devframe/helpers/') }, { name: 'devframe/recipes/*', match: id => id.startsWith('devframe/recipes/') }, { name: 'devframe/utils/launch-editor', match: id => id === 'devframe/utils/launch-editor' }, { name: 'devframe/utils/open', match: id => id === 'devframe/utils/open' }, diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 6f4cea8b..aa549b9c 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -35,7 +35,7 @@ export interface CreateDevServerOptions { * `def.cli?.distDir` is set, the dev server runs in **bridge mode** — * only `__connection.json` and the WS endpoint are mounted; the SPA * is expected to be hosted elsewhere (e.g. by a parent Vite/Nuxt - * dev server via `viteDevBridge({ devMiddleware })`). + * dev server via `devframeViteBridge` from `@devframes/vite`). */ distDir?: string /** @@ -74,7 +74,7 @@ export interface CreateDevServerOptions { * Override how authentication resolves, taking precedence over * `def.cli?.auth`. Pass `false` to skip the gate entirely (the standard * choice for a **hosted** deployment where the host manages auth — see - * {@link viteDevBridge}); a {@link DevframeAuthHandler} to install a custom + * {@link devframeViteBridge} from `@devframes/vite`); a {@link DevframeAuthHandler} to install a custom * scheme; or `true` to force devframe's interactive OTP gate on. When * omitted, auth resolves from `flags.auth` / `def.cli?.auth` (the standalone * default: gated). The `--no-auth` flag (`flags.auth === false`) still forces @@ -115,7 +115,7 @@ export interface CreateDevServerOptions { * server runs in **bridge mode**: only `__connection.json` and the WS * endpoint are mounted, with no SPA mount. The SPA is expected to be * hosted elsewhere (e.g. by a parent Vite/Nuxt dev server) — see - * `viteDevBridge({ devMiddleware })`. + * `devframeViteBridge` from `@devframes/vite`. * * Returns the underlying {@link StartedServer} handle so callers can * close it gracefully (SIGINT, hot-reload, test teardown). diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts deleted file mode 100644 index c3937d93..00000000 --- a/packages/devframe/src/helpers/vite.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' -import type { DevframeInstance } from '../adapters/initiate' -import type { DevframeAuthHandler } from '../node/auth/handler' -import type { DevframeDefinition, McpRouteOptions } from '../types/devframe' -import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' -import { resolve } from 'pathe' -import { normalizeBasePath, resolveBasePath } from '../adapters/_shared' -import { initDevframe } from '../adapters/initiate' -import { diagnostics } from '../node/diagnostics' - -export interface ViteDevBridgeOptions { - /** - * Mount base. Defaults to `def.basePath ?? '/__/'` for this hosted - * adapter — the devframe shares the origin with the host Vite app. - * - * Relative spellings like `'./'` (common for base-agnostic Nuxt builds) - * are normalized to absolute paths so they compose with Vite's connect - * router. - */ - base?: string - /** - * Dev-time middleware mode. When set, the host app owns the SPA and - * devframe serves the RPC surface through the Vite dev server itself — - * `__connection.json` for discovery and the WebSocket upgrade at - * `__ws` on Vite's own HTTP server (zero extra ports, proxy/HTTPS - * friendly). When Vite runs in middleware mode (no `httpServer`) — or a - * `port` is pinned — the socket falls back to a side-car server on its - * own port instead. - * - * - `false` (default) — static-mount the SPA at `base` with SPA - * fallback. No RPC server is started. - * - `true` — bridge mode with all defaults. - * - object — bridge mode with explicit overrides. - */ - devMiddleware?: boolean | { - /** - * Pin a side-car port for the RPC socket instead of sharing Vite's - * server. Default: share Vite's HTTP server (side-car only when Vite - * has none). - */ - port?: number - /** Override the side-car bind host. Default: `def.cli?.host ?? 'localhost'`. */ - host?: string - /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ - flags?: Record - } - /** - * Whether the bridged devframe runs its own auth gate. The RPC endpoint is - * reachable by anything that can open its socket, so it **gates by - * default**: when unset, authentication resolves through devframe's - * interactive OTP gate (unless the definition's `cli.auth` opts out), and - * the bridge prints its code/link banner to stdout. Pass a - * {@link DevframeAuthHandler} to install a custom scheme, or `false` to opt - * out for a single-user localhost host that owns the trust boundary another - * way. Only applies in bridge mode (`devMiddleware`); the static-mount mode - * starts no RPC server. - * - * @default gated (devframe's interactive OTP, unless `cli.auth` opts out) - */ - auth?: boolean | DevframeAuthHandler - /** - * Expose the bridge's route-based MCP server (Streamable-HTTP) at - * `__mcp` — on the Vite app's own origin — and advertise it in the - * bridge's `__connection.json`. Overrides `def.cli?.mcp`, `undefined` - * falls through to it, `false` disables the route regardless. Only applies - * in bridge mode (`devMiddleware`); the static-mount mode starts no server. - * - * @experimental - */ - mcp?: boolean | McpRouteOptions -} - -/** The slice of a Vite dev server the bridge plugin touches. */ -export interface DevframeViteDevServerLike { - middlewares: { - use: ((path: string, handler: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void) => void) - & ((handler: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void) => void) - } - httpServer?: NodeHttpServer | null -} - -export interface DevframeVitePlugin { - name: string - apply: 'serve' - configureServer: (server: DevframeViteDevServerLike) => void | Promise - closeBundle?: () => void | Promise -} - -/** - * Bridge a devframe into an existing Vite dev server. Returns a Vite - * plugin with two modes, picked via `options.devMiddleware`: - * - * - **static-mount mode** (default) — mounts `def.cli.distDir` at - * `options.base` with SPA fallback enabled. No RPC server is started. - * - * - **bridge mode** (`devMiddleware: true | {…}`) — skips the static - * mount; the host app owns the SPA. Devframe serves discovery - * (`__connection.json`), the WebSocket RPC upgrade - * (`__ws`, shared on Vite's own HTTP server), and the optional - * MCP route through {@link initDevframe}'s node middleware, so the - * host-served SPA can discover the endpoint via {@link connectDevframe}. - * - * The bridge **gates by default** (devframe's interactive OTP unless the - * definition's `cli.auth` opts out), printing its code/link banner to stdout, - * so a bridged devframe isn't silently reachable by anything that can open - * its socket. Pass `options.auth: false` to opt out for a single-user - * localhost host, or a {@link DevframeAuthHandler} for a custom scheme. - * - * Use bridge mode when integrating with frameworks that own the SPA - * (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one - * `dev` / `build` / `mcp` shell, reach for {@link createCac} instead. - */ -export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptions = {}): DevframeVitePlugin { - const base = normalizeMountBase(options.base ?? resolveBasePath(d, 'hosted')) - - if (!options.devMiddleware) { - const distDir = d.cli?.distDir - return { - name: `devframe:${d.id}`, - apply: 'serve', - configureServer(server) { - if (!distDir) - return - server.middlewares.use(base, serveStaticNodeMiddleware(resolve(distDir))) - }, - } - } - - const mw = options.devMiddleware === true ? {} : options.devMiddleware - let instance: DevframeInstance | undefined - - return { - name: `devframe:${d.id}`, - apply: 'serve', - async configureServer(server) { - // Vite re-invokes `configureServer` on each restart cycle; close - // the prior handle so we don't leak the WS transport. Silent catch — - // a stale handle's close failure shouldn't block a fresh start. - await instance?.close().catch(() => {}) - instance = undefined - - try { - const created = initDevframe(d, { - base, - // The host app owns the SPA in bridge mode — never mount the - // definition's own distDir here. - distDir: false, - flags: mw.flags, - host: mw.host, - // Pinned port → explicit side-car. Otherwise share Vite's own - // HTTP server; a middleware-mode Vite (no httpServer) has no - // upgrade to share, so ask for an auto-port side-car instead. - ...(mw.port != null - ? { ws: { port: mw.port } } - : server.httpServer - ? { server: server.httpServer } - : { ws: { sidecar: true } }), - // Gate by default: an unset `auth` defers to the handler - // (devframe's interactive OTP unless `cli.auth` opts out) rather - // than leaving the socket ungated. `false` opts out explicitly. - auth: options.auth, - mcp: options.mcp, - }) - server.middlewares.use(created.nodeMiddleware) - await created.ready - instance = created - } - catch (e) { - diagnostics.DF0033({ id: d.id, reason: String(e), cause: e as Error }, { method: 'warn' }) - return - } - - server.httpServer?.once('close', () => { - void instance?.close().catch(() => {}) - }) - }, - - async closeBundle() { - await instance?.close().catch(() => {}) - instance = undefined - }, - } -} - -/** - * Make `base` safe for `server.middlewares.use(path, …)`. Vite's connect - * router matches by absolute URL prefix, so relative spellings like - * `'./'` (commonly used for base-agnostic Nuxt builds) collapse to the - * origin root before the shared leading/trailing-slash normalization. - */ -function normalizeMountBase(base: string): string { - return normalizeBasePath(base.replace(/^\.\/?/, '/')) -} diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index d2522846..bdcf5f5a 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -26,8 +26,16 @@ // transport"), the fetch/connect handler pair, and teardown. `StartedServer` // is the live handle its bound tiers produce and `createDevServer` re-exposes. // - `normalizeHttpServerUrl` — a small host-side URL helper. +// - `resolveBasePath` / `normalizeBasePath` — the mount-base resolution +// `initDevframe` itself uses; a bridge (`@devframes/vite`) that mounts a +// devframe onto a host it doesn't own reuses the exact same defaulting. +// - `diagnostics` — devframe core's structured diagnostics instance +// (`DF00xx`), so a first-party integration built outside this package can +// report against the same registered codes instead of minting its own. +export { normalizeBasePath, resolveBasePath } from '../adapters/_shared' export { coerceAgentPositionalArgs } from '../node/agent-args' export type { AgentArgsFallback } from '../node/agent-args' +export { diagnostics } from '../node/diagnostics' export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' export { listLiveDevframeInstances, registerDevframeInstance } from '../node/instance-registry' diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 450f7459..657c6dfc 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -60,7 +60,7 @@ export const diagnostics = defineDiagnostics({ DF0033: { why: (p: { id: string, reason: string }) => `Failed to start dev RPC bridge for "${p.id}": ${p.reason}`, - fix: 'Verify the bridge port is free and the devframe setup function does not throw. Pin a port via `cli.port` / `cli.portRange` on the definition, or via `devMiddleware.port` on `viteDevBridge`.', + fix: 'Verify the bridge port is free and the devframe setup function does not throw. Pin a port via `cli.port` / `cli.portRange` on the definition, or via `port` on `devframeViteBridge` (`@devframes/vite`).', }, DF0034: { why: (p: { namespace: string, name: string }) => @@ -114,7 +114,7 @@ export const diagnostics = defineDiagnostics({ }, DF0052: { why: (p: { host: string, port: number, reason: string }) => `Failed to listen on ${p.host}:${p.port}: ${p.reason}`, - fix: 'The port is likely already taken by another process (often a previous devframe instance). Free it, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `devMiddleware.port` on `viteDevBridge`. The original node error is available as `error.cause`.', + fix: 'The port is likely already taken by another process (often a previous devframe instance). Free it, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `port` on `devframeViteBridge` (`@devframes/vite`). The original node error is available as `error.cause`.', }, DF0054: { why: (p: { id: string }) => `connectionMeta() was called before initDevframe("${p.id}") finished initializing.`, diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 6ce1af13..c322c80a 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -157,7 +157,7 @@ export interface ConnectionMeta { * tooling (e.g. an MCP inspector) can discover it without guessing the * path. `path` is relative to `__connection.json`'s location, like the * WebSocket `path`. `port` is set when the endpoint lives on a side-car - * server on its own port (bridge mode — `viteDevBridge`, + * server on its own port (bridge mode — `devframeViteBridge`, * `@devframes/next`): the client combines the page hostname with `port` * and resolves `path` against that origin, mirroring * {@link ConnectionMetaWebsocket.port}. diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 5dce3ab4..45329bae 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -110,7 +110,6 @@ const serverEntries = { 'adapters/initiate': 'src/adapters/initiate.ts', 'adapters/mcp': 'src/adapters/mcp/index.ts', 'cli/main': 'src/cli/main.ts', - 'helpers/vite': 'src/helpers/vite.ts', 'recipes/common-rpc-functions': 'src/recipes/common-rpc-functions.ts', 'recipes/interactive-auth': 'src/recipes/interactive-auth.ts', } diff --git a/packages/next/package.json b/packages/next/package.json index 123f4b97..731246f2 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -24,10 +24,22 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, - "./client": { + "./dev-spa": { + "types": "./dist/dev-spa.d.mts", + "default": "./dist/dev-spa.mjs" + }, + "./dev-spa/client": { "types": "./dist/client.d.mts", "default": "./dist/client.mjs" }, + "./hub": { + "types": "./dist/hub.d.mts", + "default": "./dist/hub.mjs" + }, + "./hub/client": { + "types": "./dist/hub-client.d.mts", + "default": "./dist/hub-client.mjs" + }, "./package.json": "./package.json" }, "types": "./dist/index.d.mts", @@ -41,11 +53,19 @@ "prepack": "pnpm run build" }, "peerDependencies": { + "@devframes/hub": "workspace:*", + "@devframes/hub-ui": "workspace:*", "devframe": "workspace:*", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { + "@devframes/hub": { + "optional": true + }, + "@devframes/hub-ui": { + "optional": true + }, "next": { "optional": true }, @@ -54,9 +74,12 @@ } }, "dependencies": { - "h3": "catalog:deps" + "h3": "catalog:deps", + "ufo": "catalog:deps" }, "devDependencies": { + "@devframes/hub": "workspace:*", + "@devframes/hub-ui": "workspace:*", "@types/node": "catalog:types", "@types/react": "catalog:types", "devframe": "workspace:*", diff --git a/packages/next/src/config.ts b/packages/next/src/config.ts index 69d6117b..ac505a4c 100644 --- a/packages/next/src/config.ts +++ b/packages/next/src/config.ts @@ -19,7 +19,7 @@ export interface DevframeNextConfig { * connect. Serving the base path verbatim keeps relative resolution intact. * * ```js [next.config.mjs] - * import { withDevframe } from '@devframes/next' + * import { withDevframe } from '@devframes/next/dev-spa' * * export default withDevframe({ * // ...your own Next config diff --git a/packages/next/src/dev-spa.ts b/packages/next/src/dev-spa.ts new file mode 100644 index 00000000..570171ae --- /dev/null +++ b/packages/next/src/dev-spa.ts @@ -0,0 +1,14 @@ +// `@devframes/next/dev-spa` — host a SINGLE devframe's SPA inside a Next.js +// App Router app: the config helper that lets mounted SPAs' relative assets +// resolve, and the catch-all route handler that serves one devframe. +// +// The React client that connects the page to the devframe RPC lives at +// `@devframes/next/dev-spa/client` (a `'use client'` module). +export type { DevframeNextConfig } from './config' +export { withDevframe } from './config' + +export type { + CreateDevframeNextHandlerOptions, + DevframeNextHandler, +} from './handler' +export { createDevframeNextHandler } from './handler' diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts index d84c7d89..ceb0427e 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -91,14 +91,14 @@ function defaultGetStorageDir(scope: DevframeStorageScope): string { /** * Host a **single** devframe from a Next.js App Router app — the Next - * counterpart to `viteDevBridge`, reduced to memoization + defaults over + * counterpart to `devframeViteBridge`, reduced to memoization + defaults over * `initDevframe` (Next's route handlers can't accept WS upgrades, so the * RPC socket lives on the instance's side-car port, advertised at * `__connection.json`). * * ```ts [app/%5F_my-tool/[[...path]]/route.ts] * import myDevframe from '@/devframe' - * import { createDevframeNextHandler } from '@devframes/next' + * import { createDevframeNextHandler } from '@devframes/next/dev-spa' * * export const runtime = 'nodejs' * export const dynamic = 'force-dynamic' diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 7e869428..9becb11c 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -96,7 +96,7 @@ function stripTrailingSlash(base: string): string { * 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 + * This is the hosted-adapter counterpart to `devframeViteBridge` 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 diff --git a/packages/next/src/hub-client.tsx b/packages/next/src/hub-client.tsx new file mode 100644 index 00000000..e33d1dba --- /dev/null +++ b/packages/next/src/hub-client.tsx @@ -0,0 +1,69 @@ +'use client' + +import type { DevframeClientHost, DevframeClientHostOptions } from '@devframes/hub/client' +import { createDevframeClientHost } from '@devframes/hub/client' +import { useEffect, useState } from 'react' + +export type { DevframeClientHost, DevframeClientHostOptions } from '@devframes/hub/client' + +/** Default hub mount base — mirrors `@devframes/hub`'s `DEVFRAMES_HUB_BASE`. */ +const DEVFRAMES_HUB_BASE = '/__devframes/' + +export interface UseDevframeHubClientOptions extends DevframeClientHostOptions { + /** + * Hub mount base to connect to. Forwarded as `connect.baseURL` when no + * `rpc` / `connect.baseURL` is supplied. + * + * @default '/__devframes/' + */ + base?: string +} + +/** + * Boot the devframes-hub **client runtime** inside a React (Next.js) page — + * the browser half of {@link import('./hub').nextDevframeHub}. Connects RPC to + * the hub (defaulting `base` to `/__devframes/`), assembles the shared + * `DevframeClientContext`, imports each dock's client script into the page, + * and disposes on unmount. Returns the {@link DevframeClientHost} once ready + * (`null` while connecting). + * + * Only needed when you render your own dock UI (or override the hub's `ui`). + * With the default `@devframes/hub-ui`, its injected `embedded.js` boots the + * client for you, so the page needs no client code. + * + * `renderers` is read once on mount; memoize it at the call site if it isn't a + * stable reference. + */ +export function useDevframeHubClient( + options: UseDevframeHubClientOptions = {}, +): DevframeClientHost | null { + const { base = DEVFRAMES_HUB_BASE, rpc, connect } = options + const [host, setHost] = useState(null) + + useEffect(() => { + let disposed = false + let created: DevframeClientHost | undefined + + void createDevframeClientHost({ + ...options, + ...(rpc ? { rpc } : { connect: { baseURL: base, ...connect } }), + }).then((next) => { + if (disposed) { + next.dispose() + return + } + created = next + setHost(next) + }) + + return () => { + disposed = true + created?.dispose() + setHost(null) + } + // Reconnect only when the connection target changes; `renderers` and the + // rest are captured on mount (documented above). + }, [base, rpc]) + + return host +} diff --git a/packages/next/src/hub.ts b/packages/next/src/hub.ts new file mode 100644 index 00000000..c23929b4 --- /dev/null +++ b/packages/next/src/hub.ts @@ -0,0 +1,196 @@ +import type { DevframeHubUi, HubInstance, InitHubOptions } from '@devframes/hub/initiate' +import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' +import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash } from 'ufo' + +// The "bring your own DevframeHost" seam for `initHub({ context })`. +export type { + CreateDevframeNextHostOptions, + DevframeNextHost, + DevframeNextHostMcpOptions, +} from './host' +export { createDevframeNextHost } from './host' + +export interface NextDevframeHubOptions { + /** + * Mount base the hub answers under — every frame lives at `/`, + * so the App Router needs one catch-all route under it. Default: + * `/__devframes/`. + */ + base?: string + /** + * Pin the side-car RPC/WS port. Next route handlers can't accept WebSocket + * upgrades, so the socket always runs on a side-car server; default walks a + * free port near 9777. + */ + port?: number + /** Bind host for the side-car server. Default: `localhost`. */ + host?: string + /** Working directory for the hub context. Default: `process.cwd()`. */ + cwd?: string + /** + * Devframes to mount. Load built-in plugin packages through a + * bundler-ignored dynamic `import()` (their node code + `import.meta.url` + * dist lookups don't survive static Next bundling) and pass them here — + * `initHub` resolves async/factory entries. + */ + devframes?: InitHubOptions['devframes'] + /** Prebuilt dock-renderer modules forwarded to `initHub({ renderers })`. */ + renderers?: InitHubOptions['renderers'] + /** Extra RPC declarations registered at context creation. */ + rpcDeclarations?: InitHubOptions['rpcDeclarations'] + /** Runs once the context exists and every devframe is mounted. */ + configure?: (ctx: Parameters>[0]) => void | Promise + /** + * The hub's UI slot. Defaults to `@devframes/hub-ui`'s `createUi()` (loaded + * through a bundler-ignored dynamic `import()` so its `import.meta.url` asset + * lookups resolve at request time). Pass your own {@link DevframeHubUi} to + * swap the viewer, or `false` for a headless hub. + */ + ui?: DevframeHubUi | false + /** The hub's single auth gate. Gates by default; `false` opts out. */ + auth?: InitHubOptions['auth'] + /** + * Expose the aggregate MCP endpoint at `__mcp`. Default: `true` + * (the Next hub's agent surface rides the same catch-all route). + * + * @experimental + */ + mcp?: InitHubOptions['mcp'] + /** Public origin the Next app is reachable at. Default: derived from `PORT`. */ + origin?: InitHubOptions['origin'] + /** Publish this hub in the global instance registry. Default: off. */ + register?: InitHubOptions['register'] + /** Override where persisted devframe state lives. */ + getStorageDir?: InitHubOptions['getStorageDir'] + /** Name for the hub instance (logs, diagnostics, MCP server). */ + name?: string + /** Version for the hub instance (logs, diagnostics, MCP server). */ + version?: string +} + +/** + * Build a devframes-hub for a Next.js App Router app: one `initHub()` call + * mounting every devframe under `/` behind one web-standard + * `handler`, with the RPC socket on a side-car (Next routes can't accept WS + * upgrades) and the aggregate MCP route on by default. The UI defaults to + * `@devframes/hub-ui`'s `createUi()`, loaded lazily via a bundler-ignored + * dynamic `import()` so its asset lookups resolve at request time; pass `ui` + * to swap it or `ui: false` for a headless hub. + * + * Prefer {@link nextDevframeHub} at a route module — it memoizes this on + * `globalThis` so Next's dev-time route re-evaluation reuses one instance + * instead of leaking a side-car per reload. + */ +export async function createNextDevframeHub(options: NextDevframeHubOptions = {}): Promise { + const base = normalizeBase(options.base ?? DEVFRAMES_HUB_BASE) + + const ui = options.ui === false + ? undefined + : options.ui ?? await loadDefaultUi() + + return initHub({ + base, + ...(options.cwd != null ? { cwd: options.cwd } : {}), + ...(options.host != null ? { host: options.host } : {}), + ...(options.origin != null ? { origin: options.origin } : {}), + auth: options.auth, + // Next route handlers can't accept WS upgrades — always a side-car socket. + ws: options.port != null ? { port: options.port } : { sidecar: true }, + // The Next hub's agent surface rides the same catch-all route by default. + mcp: options.mcp ?? true, + ...(ui ? { ui } : {}), + ...(options.renderers ? { renderers: options.renderers } : {}), + ...(options.rpcDeclarations ? { rpcDeclarations: options.rpcDeclarations } : {}), + ...(options.register != null ? { register: options.register } : {}), + ...(options.getStorageDir ? { getStorageDir: options.getStorageDir } : {}), + ...(options.name != null ? { name: options.name } : {}), + ...(options.version != null ? { version: options.version } : {}), + ...(options.devframes ? { devframes: options.devframes } : {}), + ...(options.configure ? { configure: options.configure } : {}), + }) +} + +/** A route-facing hub handle whose instance is memoized on `globalThis`. */ +export interface NextDevframeHubHandle { + /** The normalized mount base this hub answers under. */ + base: string + /** Delegate an App Router catch-all route straight to this. */ + handler: (request: Request) => Promise + /** Await the underlying {@link HubInstance} (building it on first access). */ + ready: () => Promise + /** Tear down the memoized instance (side-car, MCP sessions) and forget it. */ + close: () => Promise +} + +interface HubRegistry { + __devframesNextHubs?: Map> +} + +function hubRegistry(): Map> { + const g = globalThis as HubRegistry + return (g.__devframesNextHubs ??= new Map()) +} + +/** + * Route-facing devframes-hub for a Next.js App Router app, memoized on + * `globalThis` by mount base so Next's dev-time route re-evaluation reuses + * one instance instead of leaking a side-car per reload. Build it once at the + * catch-all route module and delegate the verbs to it: + * + * ```ts + * // app/__devframes/[[...path]]/route.ts + * export const runtime = 'nodejs' + * export const dynamic = 'force-dynamic' + * + * import { nextDevframeHub } from '@devframes/next/hub' + * + * const hub = nextDevframeHub({ devframes: [] }) + * export const GET = (req: Request) => hub.handler(req) + * export const POST = (req: Request) => hub.handler(req) + * export const DELETE = (req: Request) => hub.handler(req) + * ``` + */ +export function nextDevframeHub(options: NextDevframeHubOptions = {}): NextDevframeHubHandle { + const base = normalizeBase(options.base ?? DEVFRAMES_HUB_BASE) + + const ready = (): Promise => { + const registry = hubRegistry() + let instance = registry.get(base) + if (!instance) { + instance = createNextDevframeHub({ ...options, base }) + registry.set(base, instance) + } + return instance + } + + return { + base, + async handler(request) { + return (await ready()).handler(request) + }, + ready, + async close() { + const registry = hubRegistry() + const instance = registry.get(base) + if (!instance) + return + registry.delete(base) + await (await instance).close() + }, + } +} + +/** + * Load `@devframes/hub-ui`'s default UI through a bundler-ignored dynamic + * `import()`: `createUi()` resolves its prebuilt assets via `import.meta.url`, + * which only points at the published `dist` when Node loads the package at + * request time — a static import would be rewritten into a Next server chunk. + */ +async function loadDefaultUi(): Promise { + const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@devframes/hub-ui') + return (mod.createUi as () => DevframeHubUi)() +} + +function normalizeBase(base: string): string { + return cleanDoubleSlashes(withTrailingSlash(withLeadingSlash(base))) +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index b6933ffc..3770f159 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,15 +1,17 @@ -export type { DevframeNextConfig } from './config' -export { withDevframe } from './config' - -export type { - CreateDevframeNextHandlerOptions, - DevframeNextHandler, -} from './handler' -export { createDevframeNextHandler } from './handler' - -export type { - CreateDevframeNextHostOptions, - DevframeNextHost, - DevframeNextHostMcpOptions, -} from './host' -export { createDevframeNextHost } from './host' +// `@devframes/next` has no root export — it splits into two clearly-scoped +// subpaths so a consumer picks the job they're doing: +// +// • `@devframes/next/dev-spa` — host a SINGLE devframe's SPA in a +// Next.js App Router app (`withDevframe`, `createDevframeNextHandler`), +// with its React client at `@devframes/next/dev-spa/client`. +// • `@devframes/next/hub` — mount a whole devframes-hub (many +// integrations) from one catch-all route, `@devframes/hub-ui` by default, +// with a React client helper at `@devframes/next/hub/client`. +// +// Importing the bare package is almost always a mistake, so it throws with +// the pointer above instead of silently resolving to nothing. +throw new Error( + '[@devframes/next] has no root export. Import from a scoped subpath instead:\n' + + ' • "@devframes/next/dev-spa" (+ "/dev-spa/client") — host one devframe\'s SPA\n' + + ' • "@devframes/next/hub" (+ "/hub/client") — mount a devframes-hub\n', +) diff --git a/packages/next/tsdown.config.ts b/packages/next/tsdown.config.ts index 4afcf516..bbd2cc44 100644 --- a/packages/next/tsdown.config.ts +++ b/packages/next/tsdown.config.ts @@ -2,27 +2,43 @@ import { defineConfig } from 'tsdown' const tsconfig = '../../tsconfig.base.json' +// `@devframes/hub*` are optional peers, kept external so their type graphs and +// node code never inline here (the hub host loads `@devframes/hub-ui` via a +// bundler-ignored dynamic import at request time). +const nodeDeps = { + neverBundle: [/^@devframes\//], +} + export default defineConfig([ - // Node entry — the route handler + host + config helper. + // Node entries — the throwing root, the single-devframe dev-spa surface, + // and the hub host. { - entry: { index: 'src/index.ts' }, + entry: { + 'index': 'src/index.ts', + 'dev-spa': 'src/dev-spa.ts', + 'hub': 'src/hub.ts', + }, platform: 'node', tsconfig, clean: true, dts: true, + deps: nodeDeps, outExtensions: () => ({ dts: '.d.mts' }), }, - // Browser entry — the React client surface. React and devframe's client stay - // external so the consuming app provides them. + // Browser entries — the React client surfaces (single-devframe + hub). React + // and devframe's/hub's client stay external so the consuming app provides them. { - entry: { client: 'src/client.tsx' }, + entry: { + 'client': 'src/client.tsx', + 'hub-client': 'src/hub-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'], + neverBundle: ['react', 'react-dom', 'react/jsx-runtime', 'devframe/client', /^@devframes\//], }, }, ]) diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index bfee5d14..1dc1cda0 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -20,12 +20,24 @@ "sideEffects": false, "exports": { ".": { - "types": "./dist/types.d.mts", - "default": "./dist/module.mjs" + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./dev-spa": { + "types": "./dist/dev-spa.d.mts", + "default": "./dist/dev-spa.mjs" + }, + "./hub": { + "types": "./dist/hub.d.mts", + "default": "./dist/hub.mjs" + }, + "./hub/client": { + "types": "./dist/hub-client.d.mts", + "default": "./dist/hub-client.mjs" }, "./package.json": "./package.json" }, - "types": "./dist/types.d.mts", + "types": "./dist/index.d.mts", "files": [ "dist" ], @@ -36,14 +48,29 @@ "prepack": "pnpm run build" }, "peerDependencies": { + "@devframes/hub": "workspace:*", "@nuxt/kit": "^3.0.0 || ^4.0.0 || ^5.0.0-0", - "devframe": "workspace:*" + "devframe": "workspace:*", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@devframes/hub": { + "optional": true + }, + "vue": { + "optional": true + } + }, + "dependencies": { + "@devframes/vite": "workspace:*" }, "devDependencies": { + "@devframes/hub": "workspace:*", "@nuxt/kit": "catalog:build", "@types/node": "catalog:types", "devframe": "workspace:*", "nuxt": "catalog:build", - "tsdown": "catalog:build" + "tsdown": "catalog:build", + "vue": "catalog:frontend" } } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/dev-spa.ts similarity index 91% rename from packages/nuxt/src/module.ts rename to packages/nuxt/src/dev-spa.ts index 8315d588..51beae1e 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/dev-spa.ts @@ -1,6 +1,6 @@ import type { DevframeDefinition } from 'devframe' +import { devframeViteBridge } from '@devframes/vite/dev-spa' import { addPlugin, addVitePlugin, createResolver, defineNuxtModule } from '@nuxt/kit' -import { viteDevBridge } from 'devframe/helpers/vite' export interface DevframeNuxtModuleOptions { /** @@ -26,8 +26,8 @@ export interface DevframeNuxtModuleOptions { */ devframe?: DevframeDefinition /** - * Dev-time middleware mode. Mirrors `viteDevBridge`'s option of - * the same name. + * Dev-time middleware mode — whether to start `@devframes/vite`'s + * RPC bridge (`devframeViteBridge`) alongside `nuxt dev`. * * - `true` (default) — when `devframe` is set and Nuxt is in dev * mode, start the RPC bridge with all defaults. @@ -61,9 +61,9 @@ export type ModuleOptions = DevframeNuxtModuleOptions * - Injects a client plugin that calls {@link connectDevframe} once on * page load and exposes the RPC client via `useNuxtApp().$rpc`. * - When `devframe` is provided and Nuxt is in dev mode, registers a - * Vite plugin (via `addVitePlugin(viteDevBridge(devframe, { - * devMiddleware: ... }))`) that starts the RPC + WS bridge and - * serves `${baseURL}__connection.json`. + * Vite plugin (via `addVitePlugin(devframeViteBridge(devframe, { + * ... }))`) that starts the RPC + WS bridge and serves + * `${baseURL}__connection.json`. * * ```ts [nuxt.config.ts] * import devframe from './src/devframe' // defineDevframe(...) export @@ -139,13 +139,11 @@ export default defineNuxtModule({ ?? (nuxt.options.devServer as any)?.host ?? options.devframe.cli?.host - addVitePlugin(viteDevBridge(options.devframe, { + addVitePlugin(devframeViteBridge(options.devframe, { base: options.baseURL ?? './', - devMiddleware: { - port: mw.port, - host, - flags: mw.flags, - }, + port: mw.port, + host, + flags: mw.flags, }) as any) } }, diff --git a/packages/nuxt/src/hub-client.ts b/packages/nuxt/src/hub-client.ts new file mode 100644 index 00000000..9b836283 --- /dev/null +++ b/packages/nuxt/src/hub-client.ts @@ -0,0 +1,59 @@ +import type { DevframeClientHost, DevframeClientHostOptions } from '@devframes/hub/client' +import type { Ref } from 'vue' +import { createDevframeClientHost } from '@devframes/hub/client' +import { onScopeDispose, shallowRef } from 'vue' + +export type { DevframeClientHost, DevframeClientHostOptions } from '@devframes/hub/client' + +/** Default hub mount base — mirrors `@devframes/hub`'s `DEVFRAMES_HUB_BASE`. */ +const DEVFRAMES_HUB_BASE = '/__devframes/' + +export interface UseDevframeHubClientOptions extends DevframeClientHostOptions { + /** + * Hub mount base to connect to. Forwarded as `connect.baseURL` when no + * `rpc` / `connect.baseURL` is supplied. + * + * @default '/__devframes/' + */ + base?: string +} + +/** + * Boot the devframes-hub **client runtime** inside a Nuxt (Vue) client + * component — the browser half of `@devframes/nuxt/hub`. Connects RPC to the + * hub (defaulting `base` to `/__devframes/`), assembles the shared + * `DevframeClientContext`, imports each dock's client script into the page, + * and disposes when the current effect scope is torn down. Returns a ref that + * resolves to the {@link DevframeClientHost} (`null` while connecting). + * + * Client-only: call it inside `