Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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;
Expand Down
47 changes: 46 additions & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`/__<id>/`) 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 `/__<id>/` 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:
Expand Down Expand Up @@ -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

Expand Down
37 changes: 20 additions & 17 deletions examples/minimal-next-devframe-hub/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 `/__<id>/`. 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 `/__<id>/` |
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol |
| `src/client/app/icons.ts` | Offline Phosphor icons for the dock |
11 changes: 10 additions & 1 deletion examples/minimal-next-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -80,9 +80,15 @@ async function resolveTarget(absDir: string, urlPath: string): Promise<ResolvedF
}

export async function GET(request: Request): Promise<Response> {
await ensureMinimalNextDevframeHub()
const hub = await ensureMinimalNextDevframeHub()

const pathname = new URL(request.url).pathname

// A mounted devframe SPA fetches `<base>/__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 })
Expand Down
181 changes: 6 additions & 175 deletions examples/minimal-next-devframe-hub/src/client/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading