diff --git a/alias.ts b/alias.ts index 94cde74..b9cd1f7 100644 --- a/alias.ts +++ b/alias.ts @@ -65,6 +65,7 @@ export const alias = { '@devframes/plugin-terminals/cli': p('terminals/src/cli.ts'), '@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'), '@devframes/plugin-terminals': p('terminals/src/index.ts'), + '@devframes/plugin-git': p('git/src/index.ts'), 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), 'devframe/client': r('devframe/src/client/index.ts'), 'devframe': r('devframe/src'), @@ -73,6 +74,7 @@ export const alias = { '@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'), '@devframes/plugin-inspect/vite': p('inspect/src/vite.ts'), '@devframes/plugin-inspect': p('inspect/src/index.ts'), + '@devframes/a11y': p('a11y/src/devframe.ts'), } // update tsconfig.base.json — CSS aliases exist for Vite resolution only; diff --git a/docs/guide/hub.md b/docs/guide/hub.md index a3ba86d..d039810 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -43,6 +43,46 @@ await mountDevframe(ctx, myDevframe) Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `mountDevframe`. +### Connecting embedded SPAs + +A mounted devframe's SPA loads in an iframe at its base (`/__/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base. `mountDevframe` serves it there by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`, so the SPA discovers the RPC/WS endpoint directly. Implement `mountConnectionMeta` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base: + +```ts +const host: DevframeHost = { + mountStatic(base, distDir) { /* serve files */ }, + mountConnectionMeta(base) { + // serve `${base}__connection.json` → { backend: 'websocket', websocket: port } + }, + resolveOrigin() { /* … */ }, + getStorageDir(scope) { /* … */ }, +} +``` + +Hosts that omit `mountConnectionMeta` fall back to same-origin window inheritance, which connects an embedded SPA only when it shares an origin with the hub UI. + +### Bundled hosts (Next.js) + +Dev servers with a module bundler (Next's Turbopack/webpack) statically analyse server imports. Plugin packages resolve their SPA dist with `new URL('../dist/...', import.meta.url)` and lazy-load node-side code — child processes, the native `node-pty` PTY backend — that resolves at runtime, not at bundle time. Load them with a dynamic `import()` carrying ignore comments so the bundler keeps them as a runtime Node import: + +```ts +const pkgs = ['@devframes/plugin-git', '@devframes/plugin-terminals'] +const defs = await Promise.all( + pkgs.map(p => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ p)), +).then(mods => mods.map(m => m.default)) + +for (const def of defs) + await mountDevframe(ctx, def) +``` + +Each mounted SPA is served at `/__/` and references its assets relatively (`./_next/…`, `./assets/…`). Disable the bundler's trailing-slash redirect so those paths resolve under the mount base: + +```js +// next.config.mjs +export default { skipTrailingSlashRedirect: true } +``` + +[`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) is a working Next.js App Router host that mounts the built-in plugins this way. + ### Duplicate devframes When a devframe sharing an already-mounted `id` is mounted onto the same hub, its `duplicationStrategy` decides what happens. By default the first registration wins: @@ -105,7 +145,12 @@ Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:u ## Example -See [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) for a ~120-line Vite plugin that wires the hub end to end with a vanilla DOM UI. Every framework's hub host follows the same shape: a thin layer that adapts the framework's dev server to the hub. +Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-server, inspect, a11y) behind an icon dock — the same shape [vite-devtools](https://github.com/vitejs/devtools) wears as the full Vite viewer, shrunk to the smallest thing you can build your own viewer from: + +- [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) — a ~120-line Vite plugin host with a vanilla DOM UI. +- [`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) — the same protocol hosted from a Next.js App Router app. + +Every framework's hub host follows the same shape: a thin `DevframeHost` adapter over the framework's dev server, with the dock/commands/messages/terminals protocol unchanged above it. ## Diagnostics diff --git a/examples/minimal-next-devframe-hub/README.md b/examples/minimal-next-devframe-hub/README.md index bd89a0e..a53cfec 100644 --- a/examples/minimal-next-devframe-hub/README.md +++ b/examples/minimal-next-devframe-hub/README.md @@ -1,6 +1,8 @@ # Minimal Next Devframe Hub -A protocol-witness example. The `src/client/devframe/minimal-next-devframe-hub.ts` file wires `@devframes/hub` into a Next.js App Router app by lazily starting a side-car RPC/WS server from a Node route handler. +A tiny, copyable **vite-devtools-style hub on Next.js**. [vite-devtools](https://github.com/vitejs/devtools) is the full Vite viewer built on `@devframes/hub`; this example wears the same shape — an icon dock, an iframe stage, a subsystem drawer — but hosts it from a Next.js App Router app, lazily starting a side-car RPC/WS server from a Node route handler. It's the reference for bringing the same integrations to any non-Vite host. + +`src/client/devframe/minimal-next-devframe-hub.ts` is the entire host. ## Run it @@ -9,28 +11,29 @@ pnpm install pnpm --filter minimal-next-devframe-hub dev ``` -Open the printed URL. You should see: +Open the printed URL. The dock on the left lists every mounted tool with its icon: + +- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each mounted with `mountDevframe` +- **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path -- A status line showing the RPC backend -- A **Docks** list with hub built-ins and the mounted demo devframe -- A **Commands** list populated from server-side registrations -- A **Messages** list populated via `messages.add()` on the server -- A **Terminals** list, empty unless a devframe registers one -- A button that exercises `hub:commands:execute` by dispatching the sample ping command +Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`. ## What the example proves -- `createHubContext()` boots a hub without any Vite-specific code path -- A `DevframeHost` impl plugs Next host specifics into the hub uniformly -- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock -- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed -- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json` +- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly +- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock and serves both its SPA and its `__connection.json`, so the embedded SPA connects straight back to the hub +- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — byte-for-byte the same protocol the Vite host speaks + +## Hosting built-in plugins in a bundler + +The plugins run node-side (child processes, the native `node-pty` PTY backend) and resolve their SPA dist via `new URL(..., import.meta.url)`. Next's bundler would try to inline that, so the host loads them through a bundler-ignored dynamic `import()` and sets `skipTrailingSlashRedirect` (see `next.config.mjs`) so each SPA's relative assets resolve under `/__/`. This is the recipe for any bundled (webpack/Turbopack) host. ## Files | File | Role | |---|---| -| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — creates hub context and side-car WS | -| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Connection-meta endpoint for `/__hub/__connection.json` that starts the singleton host | -| `src/client/devframe/demo-devframe.ts` | A sample `DevframeDefinition` that plugs into the host | -| `src/client/app/page.tsx` | The browser-side UI that consumes the hub protocol | +| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — hub context, static-mount registry, side-car WS | +| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Boots the singleton host and serves `/__hub/__connection.json` | +| `src/client/app/%5F_[id]/[[...path]]/route.ts` | Serves each mounted SPA and its connection meta under `/__/` | +| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol | +| `src/client/app/icons.ts` | Offline Phosphor icons for the dock | diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index 8fac2dc..576ae15 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -6,22 +6,31 @@ "description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub", "scripts": { - "dev": "next dev src/client", + "dev": "next dev src/client -H 0.0.0.0", "build": "next build src/client", "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@devframes/a11y": "workspace:*", "@devframes/hub": "workspace:*", + "@devframes/plugin-code-server": "workspace:*", + "@devframes/plugin-git": "workspace:*", + "@devframes/plugin-inspect": "workspace:*", + "@devframes/plugin-terminals": "workspace:*", + "@internal/design": "workspace:*", "devframe": "workspace:*", "next": "catalog:frontend", "react": "catalog:frontend", "react-dom": "catalog:frontend" }, "devDependencies": { + "@iconify-json/ph": "catalog:frontend", "@types/react": "catalog:types", "@types/react-dom": "catalog:types", + "@unocss/postcss": "catalog:frontend", "get-port-please": "catalog:deps", "pathe": "catalog:deps", + "unocss": "catalog:frontend", "vitest": "catalog:testing", "ws": "catalog:deps" } diff --git a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts index ea1d1b6..6409239 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts +++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' import { extname, join, normalize, resolve, sep } from 'pathe' -import { ensureMinimalNextDevframeHub, getStaticMount } from '../../../devframe/minimal-next-devframe-hub' +import { ensureMinimalNextDevframeHub, getStaticMount, isConnectionMetaPath } from '../../../devframe/minimal-next-devframe-hub' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -80,9 +80,15 @@ async function resolveTarget(absDir: string, urlPath: string): Promise { - await ensureMinimalNextDevframeHub() + const hub = await ensureMinimalNextDevframeHub() const pathname = new URL(request.url).pathname + + // A mounted devframe SPA fetches `/__connection.json` to discover the + // side-car WS endpoint. Answer it with the hub's connection meta. + if (isConnectionMetaPath(pathname)) + return Response.json(hub.connectionMeta) + const hit = getStaticMount(pathname) if (!hit) return new Response(null, { status: 404 }) diff --git a/examples/minimal-next-devframe-hub/src/client/app/globals.css b/examples/minimal-next-devframe-hub/src/client/app/globals.css index 41a0966..4c9ee56 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/globals.css +++ b/examples/minimal-next-devframe-hub/src/client/app/globals.css @@ -1,176 +1,7 @@ -:root { - color-scheme: light dark; - font-family: system-ui, sans-serif; - line-height: 1.5; -} +/* The hub UI's design tokens, base layer, and component vocabulary come from the + shared devframe design system. `@unocss` is replaced by UnoCSS (via + `@unocss/postcss`, see ../postcss.config.mjs) with the generated preflights + + utilities; `@internal/design/theme.css` (imported after this file in + layout.tsx) supplies the `--df-*` token values + base element styling. */ -body { - margin: 0; - height: 100vh; - overflow: hidden; -} - -.app-shell { - display: grid; - grid-template-columns: 220px 1fr; - grid-template-rows: auto 1fr auto; - grid-template-areas: - "header header" - "sidebar main" - "footer footer"; - height: 100vh; -} - -.app-header { - grid-area: header; - padding: 0.75rem 1rem; - border-bottom: 1px solid color-mix(in srgb, currentcolor 20%, transparent); -} - -.app-header h1 { - margin: 0; - font-size: 1rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.app-header p { - margin: 0.25rem 0 0; - font-family: ui-monospace, monospace; - font-size: 0.8rem; - opacity: 0.7; -} - -.app-sidebar { - grid-area: sidebar; - border-right: 1px solid color-mix(in srgb, currentcolor 20%, transparent); - padding: 0.75rem; - overflow: auto; -} - -.app-main { - grid-area: main; - overflow: hidden; - background: color-mix(in srgb, currentcolor 3%, transparent); -} - -.app-main iframe { - width: 100%; - height: 100%; - border: 0; - display: block; -} - -.app-footer { - grid-area: footer; - display: flex; - gap: 1rem; - padding: 0.75rem 1rem; - border-top: 1px solid color-mix(in srgb, currentcolor 20%, transparent); - max-height: 30vh; - overflow: auto; -} - -.app-footer section { - flex: 1; - min-width: 0; -} - -h2 { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.8; - margin: 0 0 0.5rem; -} - -ul { - list-style: none; - padding-left: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -li { - padding: 0.4rem 0.6rem; - border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); - border-radius: 0.4rem; - font-family: ui-monospace, monospace; - font-size: 0.8rem; -} - -li.muted { - opacity: 0.5; - font-style: italic; -} - -code { - font-family: ui-monospace, monospace; - background: color-mix(in srgb, currentcolor 10%, transparent); - padding: 0.1em 0.35em; - border-radius: 0.25em; -} - -.app-sidebar ul li { - padding: 0; - border: 0; - background: transparent; -} - -.app-sidebar button { - width: 100%; - text-align: left; - padding: 0.5rem 0.6rem; - font: inherit; - font-size: 0.85rem; - background: transparent; - border: 1px solid transparent; - border-radius: 0.4rem; - cursor: pointer; - color: inherit; -} - -.app-sidebar button:hover { - background: color-mix(in srgb, currentcolor 8%, transparent); -} - -.app-sidebar button.active { - background: color-mix(in srgb, currentcolor 15%, transparent); - border-color: color-mix(in srgb, currentcolor 30%, transparent); -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: flex-start; -} - -button { - font: inherit; - font-size: 0.85rem; - padding: 0.4rem 0.8rem; - border-radius: 0.4rem; - border: 1px solid currentcolor; - background: transparent; - cursor: pointer; - color: inherit; -} - -button:hover { - background: color-mix(in srgb, currentcolor 10%, transparent); -} - -#status.error span { - color: #c33; -} - -#status.ready span { - color: #2a7; -} - -.badge { - margin-left: 0.35rem; -} +@unocss; diff --git a/examples/minimal-next-devframe-hub/src/client/app/icons.ts b/examples/minimal-next-devframe-hub/src/client/app/icons.ts new file mode 100644 index 0000000..b79a465 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/icons.ts @@ -0,0 +1,27 @@ +// @unocss-include +// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS +// `preset-icons` class. Keeping the class strings literal lets UnoCSS +// statically extract them and inline only these glyphs from `@iconify-json/ph` +// at build time — the same icon strategy vite-devtools uses. Add a row here to +// support another dock icon. +const ICON_CLASS: Record = { + 'ph:git-branch-duotone': 'i-ph-git-branch-duotone', + 'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone', + 'ph:code-duotone': 'i-ph-code-duotone', + 'ph:stethoscope-duotone': 'i-ph-stethoscope-duotone', + 'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone', + 'ph:rocket-duotone': 'i-ph-rocket-duotone', + 'ph:wrench-duotone': 'i-ph-wrench-duotone', + 'ph:plug-duotone': 'i-ph-plug-duotone', +} + +/** + * Resolve a dock icon to its UnoCSS class, or an empty string when the icon + * isn't mapped (the caller falls back to a text initial). + */ +export function iconClass(name: string | { light: string, dark: string } | undefined): string { + if (!name) + return '' + const id = typeof name === 'string' ? name : name.light + return ICON_CLASS[id] ?? '' +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/layout.tsx b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx index 5090a20..8146901 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/layout.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx @@ -1,15 +1,22 @@ import type { Metadata } from 'next' import type { ReactNode } from 'react' import './globals.css' +import '@internal/design/theme.css' export const metadata: Metadata = { title: 'Minimal Next Devframe Hub', description: 'A Next.js host for the @devframes/hub protocol.', } +// Follow the OS theme before paint (@internal/design dark: is class-based). +const themeScript = `(function(){try{if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')}catch(e){}})();` + export default function RootLayout({ children }: { children: ReactNode }) { return ( - + + + - -
-
-

Minimal Vite Devframe Hub

-

Connecting…

+ +
+
+

Minimal Vite Devframe Hub

+

Connecting…

+

a ~120-line, vite-devtools-style hub you can copy

- +
+ -
- -
+
+ +
+
-