diff --git a/AGENTS.md b/AGENTS.md index 38bb0cf..787c778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,20 @@ 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`. +### Design system + +All five built-in plugins — and every example under `examples/` — share one design system, `@internal/design`, so they look and feel like one product across frameworks (Git is React/Next, terminals is Svelte, code-server is vanilla DOM, inspect is Vue, a11y is Solid, the examples are Preact/Next/vanilla). It's a private, source-only package — never built or published; consumers import its TypeScript/CSS directly (resolved through `alias.ts` for bundlers and the package `exports` for config loaders), so editing it needs no rebuild. + +- **Respect the skills.** This design system is built to the `antfu` and `antfu-design` skills (UnoCSS-first, class-based semantic tokens, dual light/dark, anti-slop) — load and follow them when building or changing any UI here. The surfaces deliberately echo the upstream devtools they descend from; reference their UI/UX when in doubt: [`antfu/node-modules-inspector`](https://github.com/antfu/node-modules-inspector), [`antfu/vite-plugin-inspect`](https://github.com/antfu/vite-plugin-inspect), [`eslint/config-inspector`](https://github.com/eslint/config-inspector), and [`vitejs/devtools` → `packages/rolldown`](https://github.com/vitejs/devtools/tree/main/packages/rolldown). +- **One preset to extend.** Each consumer's `uno.config.ts` is just `presets: [presetDevframe()]` (imported from `@internal/design/preset`). The preset bundles `presetWind4` + `presetIcons` (Phosphor) + the directive/variant-group transformers, the semantic token theme, and the shared `df-*` component shortcuts (which it safelists). Don't re-declare presets, palettes, or shortcuts per consumer. +- **One token source.** Import `@internal/design/theme.css` once on the page (after the generated UnoCSS stylesheet so its base layer wins). Token *values* (the `--df-*` custom properties, light + dark via the `.dark` class) live only there — never hardcode a palette. The brand primary is devframe's sage green; flip the OS preference onto `` from the SPA entry. +- **Shared component vocabulary.** Build UI from the `df-*` classes (`df-btn`, `df-badge`, `df-tab`, `df-navtab`, `df-nav`, `df-nav-brand`, `df-toolbar`, `df-card`, `df-input`, `df-dot`, `df-tag-*`, …) and the semantic token utilities (`bg-primary`, `text-muted-foreground`, `bg-card`, `border-border`, …). Markup differs per framework; the classes resolve identically, which is what keeps the surfaces consistent. +- **Component builders.** Prefer the framework-neutral recipes from `@internal/design/components` (`button`, `iconButton`, `badge`, `tab`, `tabsList`, `navTab`, `nav`, `navBrand`, `toolbar`, `card`, `panel`, `input`, `link`, `dot`, `spinner`, `tag`) over hand-written class strings — they return the canonical `df-*` classes and read the same in React (`className=`), Svelte/Vue (`class=`), vanilla DOM and Solid. Because these classes are assembled at runtime, the preset safelists the `df-*` vocabulary so UnoCSS always emits it; add new component classes to `DF_SAFELIST` when you extend the set. +- **One nav, three buttons, one tab selector — strictly.** Every surface opens with the same top bar: `nav()` (a single row, one fixed height) led by `navBrand()` (a primary-tinted `i-ph-*` icon + the product name). Buttons come in exactly three forms and nothing else: a **text button** (`button({ variant, size })`), an **icon button** (`iconButton()` — bordered), and a **borderless icon button** (`iconButton({ variant: 'ghost' })`). Multi-view tools (inspect, git) switch views with the one shared segmented selector — `tabsList()` wrapping `tab()` buttons that carry `data-state="active"` and a leading `i-ph-*` icon per tab. Don't invent bespoke nav bars, button shapes, or tab styles. +- **Icons** come from the shared Phosphor set (`i-ph-*`, duotone preferred) via `presetIcons` — use them everywhere instead of per-consumer icon libraries or bespoke SVG. +- **A surface keeping its own component CSS** (inspect, a11y) re-bases its color/radius tokens onto the `--df-*` variables rather than hardcoding a palette, so it tracks the shared theme. +- **Plain `.ts`/vanilla views** must opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite, or `content.filesystem` globs for the `@unocss/postcss` setup Next uses), since UnoCSS only scans framework files by default. + ### Devframe design principles These reinforce devframe's positioning as "the container for one devtool integration, portable to multiple viewers". When in doubt, err on the side of "devframe provides primitives, the hub provides UX". diff --git a/alias.ts b/alias.ts index 12fdbe5..05744d1 100644 --- a/alias.ts +++ b/alias.ts @@ -45,6 +45,11 @@ export const alias = { '@devframes/hub': r('hub/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), + '@internal/design/preset': r('design/src/preset.ts'), + '@internal/design/components': r('design/src/components.ts'), + '@internal/design/tokens': r('design/src/tokens.ts'), + '@internal/design/theme.css': r('design/src/theme.css'), + '@internal/design': r('design/src/index.ts'), '@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'), '@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'), '@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'), @@ -60,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'), @@ -68,13 +74,21 @@ 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/plugin-a11y/client': p('a11y/src/client/index.ts'), + '@devframes/plugin-a11y/node': p('a11y/src/node/index.ts'), + '@devframes/plugin-a11y/cli': p('a11y/src/cli.ts'), + '@devframes/plugin-a11y/vite': p('a11y/src/vite.ts'), + '@devframes/plugin-a11y': p('a11y/src/index.ts'), } -// update tsconfig.base.json +// update tsconfig.base.json — CSS aliases exist for Vite resolution only; +// TypeScript resolves `*.css` side-effect imports through ambient shims. const raw = fs.readFileSync(join(root, 'tsconfig.base.json'), 'utf-8').trim() const tsconfig = JSON.parse(raw) tsconfig.compilerOptions.paths = Object.fromEntries( - Object.entries(alias).map(([key, value]) => [key, [`./${relative(root, value)}`]]), + Object.entries(alias) + .filter(([key]) => !key.endsWith('.css')) + .map(([key, value]) => [key, [`./${relative(root, value)}`]]), ) const newRaw = JSON.stringify(tsconfig, null, 2) if (newRaw !== raw) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 36bd2fa..9827faf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -57,6 +57,28 @@ function helpersItems(prefix: string): DefaultTheme.NavItemWithLink[] { ] } +function pluginsItems(prefix: string): DefaultTheme.NavItemWithLink[] { + return [ + { text: 'Overview', link: `${prefix}/plugins/` }, + { text: 'Devframe Inspector', link: `${prefix}/plugins/inspect` }, + { text: 'Accessibility Inspector', link: `${prefix}/plugins/a11y` }, + { text: 'Git', link: `${prefix}/plugins/git` }, + { text: 'Terminals', link: `${prefix}/plugins/terminals` }, + { text: 'Code Server', link: `${prefix}/plugins/code-server` }, + ] +} + +function examplesItems(prefix: string): DefaultTheme.NavItemWithLink[] { + return [ + { text: 'Overview', link: `${prefix}/examples/` }, + { text: 'files-inspector', link: `${prefix}/examples/files-inspector` }, + { text: 'streaming-chat', link: `${prefix}/examples/streaming-chat` }, + { text: 'next-runtime-snapshot', link: `${prefix}/examples/next-runtime-snapshot` }, + { text: 'minimal-vite-devframe-hub', link: `${prefix}/examples/minimal-vite-devframe-hub` }, + { text: 'minimal-next-devframe-hub', link: `${prefix}/examples/minimal-next-devframe-hub` }, + ] +} + export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { return [ { @@ -71,6 +93,14 @@ export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { text: 'Helpers', items: helpersItems(prefix), }, + { + text: 'Plugins', + items: pluginsItems(prefix), + }, + { + text: 'Examples', + items: examplesItems(prefix), + }, { text: 'Error Reference', link: `${prefix}/errors/`, @@ -88,6 +118,8 @@ export function devframeNav(prefix = ''): DefaultTheme.NavItem[] { { text: 'Guide', items: guideItems(prefix) }, { text: 'Adapters', items: adaptersItems(prefix) }, { text: 'Helpers', items: helpersItems(prefix) }, + { text: 'Plugins', items: pluginsItems(prefix) }, + { text: 'Examples', items: examplesItems(prefix) }, { text: 'Errors', link: `${prefix}/errors/` }, { text: `v${pkg.version}`, diff --git a/docs/adapters/dev.md b/docs/adapters/dev.md index 12124dc..727509a 100644 --- a/docs/adapters/dev.md +++ b/docs/adapters/dev.md @@ -30,8 +30,34 @@ process.on('SIGINT', () => handle.close().then(() => process.exit(0))) | `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. | | `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). | | `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. | +| `ws` | `def.cli?.ws` | How the browser reaches the RPC WebSocket — see below. | | `onReady` | — | Callback when the WS server is bound. | +## WebSocket endpoint + +By default the RPC socket shares the HTTP server's port and binds to the `__devframe_ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option): + +```ts +defineDevframe({ + // 1. Same server, a custom route (default route is `__devframe_ws`): + cli: { ws: { route: '__sockets' } }, + + // 2. A dedicated port on the same host: + cli: { ws: { port: 9788 } }, + + // 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay): + cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } }, +}) +``` + +| Field | Scenario | Advertised `websocket` | +|-------|----------|------------------------| +| `route` | same server, different route | `{ path: }` (same origin) | +| `port` | different port | `{ port, path: }` (page host) | +| `url` | remote, different origin | the URL string, used verbatim | + +Precedence is `url` > `port` > `route`. In the remote case the dev server still hosts the socket locally on `route`; point your tunnel at it. + ## Port resolution `resolveDevServerPort(def, opts?)` resolves a port up-front (to print or log it) before the server starts: diff --git a/docs/examples/files-inspector.md b/docs/examples/files-inspector.md new file mode 100644 index 0000000..03729c1 --- /dev/null +++ b/docs/examples/files-inspector.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# files-inspector + +Lists the files in the current working directory and renders them through a **Preact** SPA. A node-modules-inspector-style demo that exercises every devframe surface end to end. + +Package: `files-inspector-example` · framework: **Preact + Vite** + +## What it shows + +- **CLI dev server** — `node bin.mjs` boots an HTTP + WebSocket server backing live RPC. +- **Static build** — `node bin.mjs build` produces a self-contained directory (SPA + baked RPC dump) deployable to any static host. +- **Runtime base discovery** — the client is built with `vite.base: './'` and reads `document.baseURI` at runtime, so the same `dist/client` works under any base path without rebuilding. +- **Two RPC types** — `:list-files` is a `query` baked into the dump; `:get-cwd` is a `static` RPC. + +## Run it + +```sh +pnpm -C examples/files-inspector run build # build the Preact client +pnpm -C examples/files-inspector run dev # CLI dev server (live RPC) +pnpm -C examples/files-inspector run cli:build # static deploy → dist/static +``` + +The dev server prints its URL. Serve `dist/static` from any static host — relative asset paths make it portable. + +## Source + +[`examples/files-inspector`](https://github.com/devframes/devframe/tree/main/examples/files-inspector) diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..06c3c77 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,32 @@ +--- +outline: deep +--- + +# Examples + +End-to-end examples that exercise the full adapter surface, each a runnable app in the repository. Like the [built-in plugins](/plugins/), they are written across different UI frameworks on purpose: the node-side definition stays the same while the browser bundle varies, so the set demonstrates that devframe is framework-agnostic at both the plugin and the host level. + +| Example | UI framework | What it shows | +|---------|--------------|---------------| +| [files-inspector](./files-inspector) | Preact | Lists files in the cwd via RPC; exercises the CLI dev / build / spa surfaces. | +| [streaming-chat](./streaming-chat) | Preact | Streams synthetic chat tokens server → client, with history kept in shared state. | +| [next-runtime-snapshot](./next-runtime-snapshot) | React (Next.js) | A Next.js App Router SPA over RPC, surfacing the host Node runtime. | +| [minimal-vite-devframe-hub](./minimal-vite-devframe-hub) | Vanilla TypeScript (Vite) | A ~120-line Vite host wiring `@devframes/hub` end to end. | +| [minimal-next-devframe-hub](./minimal-next-devframe-hub) | React (Next.js) | The same hub protocol, hosted from a Next.js route handler. | + +## Two kinds of example + +The first three are **single-tool devframes** — one `DevframeDefinition` deployed through the [adapters](/adapters/), showing how RPC, streaming, and a chosen SPA framework fit together. + +The last two are **hub hosts** built on [`@devframes/hub`](/guide/hub). They are protocol witnesses: each is a small host that exercises every hub subsystem (docks, commands, messages, terminals) so you can read one file and see the whole shape. One is a Vite plugin; the other a Next.js route handler — same hub, different host runtime. + +## Run any example + +Each example ships its own scripts; from the repository root: + +```sh +pnpm install +pnpm --filter dev +``` + +See the individual pages for the package name, the build / static-deploy commands, and what to look for in the running app. diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md new file mode 100644 index 0000000..eb93375 --- /dev/null +++ b/docs/examples/minimal-next-devframe-hub.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# minimal-next-devframe-hub + +The same hub protocol as the [Vite host](./minimal-vite-devframe-hub), hosted from a **Next.js** App Router app. It wires [`@devframes/hub`](/guide/hub) by lazily starting a side-car RPC / WebSocket server from a Node route handler — proof that the hub is host-runtime-agnostic. + +Package: `minimal-next-devframe-hub` · framework: **React (Next.js)** + +## What it proves + +- `createHubContext()` boots a hub without any Vite-specific code path. +- A `DevframeHost` implementation plugs the Next host specifics into the hub uniformly. +- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock. +- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. +- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`, which starts the singleton host on demand. + +## Run it + +```sh +pnpm install +pnpm --filter minimal-next-devframe-hub dev +``` + +Open the printed URL to see the docks, commands, messages, and terminals lists, plus a button that dispatches a sample command through `hub:commands:execute`. + +## Source + +[`examples/minimal-next-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) diff --git a/docs/examples/minimal-vite-devframe-hub.md b/docs/examples/minimal-vite-devframe-hub.md new file mode 100644 index 0000000..e748eb9 --- /dev/null +++ b/docs/examples/minimal-vite-devframe-hub.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# minimal-vite-devframe-hub + +A protocol-witness host: roughly 120 lines of Vite plugin code that wire [`@devframes/hub`](/guide/hub) into a Vite dev server. The browser UI is plain **vanilla TypeScript**, so nothing distracts from the hub protocol itself. Every framework's hub host follows the same shape. + +Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)** + +## What it proves + +- `createHubContext()` boots a hub without any Vite-specific code path. +- A `DevframeHost` implementation plugs framework specifics (storage paths, origin resolution) 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 kit's `__connection.json` middleware. + +## Run it + +```sh +pnpm install +pnpm --filter minimal-vite-devframe-hub dev +``` + +Open the printed URL to see the docks, commands, messages, and terminals lists the hub exposes, plus a button that dispatches a sample command through `hub:commands:execute`. + +## Source + +[`examples/minimal-vite-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) diff --git a/docs/examples/next-runtime-snapshot.md b/docs/examples/next-runtime-snapshot.md new file mode 100644 index 0000000..8d9ba9b --- /dev/null +++ b/docs/examples/next-runtime-snapshot.md @@ -0,0 +1,32 @@ +--- +outline: deep +--- + +# next-runtime-snapshot + +A **Next.js App Router** SPA over RPC, surfacing the host Node runtime — system info, memory, and environment variables. It shows that a React + Next.js build is a drop-in replacement for a Preact + Vite SPA: devframe serves the static export, and the client calls into the host Node process through the same type-safe RPC. + +Package: `next-runtime-snapshot-example` · framework: **React (Next.js)** + +## What it shows + +- `…:system` — a `static` RPC. Runs once at build time when baked into a static dump, otherwise resolved live over WebSocket. Returns Node version, platform / arch, pid, cwd, and start time. +- `…:memory` — a `query` RPC the UI re-invokes from a refresh button. +- `…:env` — a `query` with valibot-validated args, listing environment variables matching a regex and redacting keys that look secret. +- Next.js App Router with `'use client'` components calling `connectDevframe()` once, then sharing the scoped client through React context. + +The Next.js config carries three non-defaults that each map to a devframe design principle: `output: 'export'` (devframe owns the server), `assetPrefix: '.'` (relative assets so the same build works at any base), and `trailingSlash: true` (composes with devframe's directory-with-index static resolution). + +## Run it + +```sh +pnpm -C examples/next-runtime-snapshot run build # next build → static export +pnpm -C examples/next-runtime-snapshot run dev # devframe CLI dev server +pnpm -C examples/next-runtime-snapshot run cli:build # static deploy → dist/static +``` + +The three cards populate from RPC; the static deploy still works because the `static` and `query` RPCs that opted into the dump are baked at build time. + +## Source + +[`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot) diff --git a/docs/examples/streaming-chat.md b/docs/examples/streaming-chat.md new file mode 100644 index 0000000..4a3eec4 --- /dev/null +++ b/docs/examples/streaming-chat.md @@ -0,0 +1,33 @@ +--- +outline: deep +--- + +# streaming-chat + +A **Preact** demo of devframe's [streaming-channel API](/guide/streaming) combined with [shared state](/guide/shared-state) for persistent chat history. The server emits synthesized "tokens" one at a time over a streaming channel, while the conversation log lives in shared state so it survives reloads, syncs across panels, and replays cleanly when a client re-joins mid-stream. + +Package: `streaming-chat-example` · framework: **Preact + Vite** + +## What it shows + +- A scoped context (`ctx.scope('devframe-streaming-chat')`) auto-namespaces every id. +- `my.rpc.streaming.create('tokens', …)` registers a streaming channel for low-latency token rendering. +- `my.rpc.sharedState('history', …)` keeps the message log on the server; each `send` appends a user + assistant pair atomically. +- The producer streams tokens live, then commits the joined content back to shared state when done — so refreshes and new clients see the finished message immediately. +- `reader.cancel()` aborts mid-stream; the assistant message is marked cancelled with whatever content accumulated. +- `replayWindow` lets a panel reopened mid-stream replay buffered tokens before resuming live. + +To wire it to a real LLM, replace the fake token generator in `src/devframe.ts` with anything that yields strings — the stream's `signal` propagates cancellation from the browser all the way to the upstream request. + +## Run it + +```sh +pnpm -C examples/streaming-chat run build +pnpm -C examples/streaming-chat run dev +``` + +Open the printed URL, type a prompt, watch tokens stream in, refresh mid-conversation, and cancel a long answer. + +## Source + +[`examples/streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) diff --git a/docs/guide/client.md b/docs/guide/client.md index d3f2cc0..facad41 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -183,16 +183,24 @@ With caching on, `query` / `static` function responses are memoized per argument ## Discovery (`__connection.json`) -Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect: +Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`__devframe_ws`) next to the meta file — and advertises it as a relative path: ```json { "backend": "websocket", - "websocket": "ws://localhost:9999/__ws" + "websocket": { "path": "__devframe_ws" } } ``` -or for static mode: +The client resolves that path against the origin it loaded from, swapping `http`→`ws` / `https`→`wss`. It never trusts a host or port baked into the descriptor, so the connection follows the page through a reverse proxy that rewrites the domain, port, or subpath. + +The `websocket` field also accepts: + +- A `number` — a port on the page's hostname (`ws(s)://:`). +- A full `ws://`/`wss://` URL string — used verbatim for a fixed cross-origin endpoint. +- `{ port }` / `{ host }` — a cross-origin endpoint (e.g. a side-car server on its own port), rooted at that host/port rather than the page origin. + +For static mode: ```json { "backend": "static" } 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/docs/helpers/vite-bridge.md b/docs/helpers/vite-bridge.md index 3a8bfb2..6eabe76 100644 --- a/docs/helpers/vite-bridge.md +++ b/docs/helpers/vite-bridge.md @@ -21,7 +21,9 @@ export default defineConfig({ ## 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. +- **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 `/__devframe_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 an existing HTTP server and a route to [`startHttpAndWs`](/adapters/dev) via its `server` and `path` options. Devframe routes only that upgrade path and leaves the rest (Vite's HMR socket included) untouched. ## Options diff --git a/docs/plugins/a11y.md b/docs/plugins/a11y.md new file mode 100644 index 0000000..f22c8b7 --- /dev/null +++ b/docs/plugins/a11y.md @@ -0,0 +1,44 @@ +--- +outline: deep +--- + +# Accessibility Inspector + +An accessibility inspector that runs [axe-core](https://github.com/dequelabs/axe-core) against a host application, lists the WCAG A/AA violations in a **Solid** panel, and highlights the offending element in the page when you hover a warning. + +Package: `@devframes/a11y` · framework: **Solid + Vite** + +## What it does + +The plugin is three pieces, two of them browser-side: + +| Piece | Runs in | Role | +|-------|---------|------| +| **Agent** | the host app's page | runs axe-core, broadcasts the report, draws the highlight ring | +| **Panel** | the devtools iframe | the Solid SPA: lists violations, fires highlight / clear on hover | +| **Node** | the devframe backend | the `get-config` RPC (impact taxonomy) — live in dev, baked in a static build | + +The agent and panel talk over a same-origin `BroadcastChannel` rather than the RPC backend, so the scan-and-highlight loop works identically whether the plugin runs as a live dev server or as a baked static build. Devframe deliberately provides no access to the host application's DOM, so the agent is the author-provided bridge: load one module script in the page you want to check and it scans, reports, and highlights on demand. + +## Run the demo + +The Accessibility Inspector lives in the repository as a reference plugin. Its demo serves an intentionally-broken host page and the panel from one origin so they share the channel: + +```sh +pnpm -C plugins/a11y build # build the panel + the agent bundle +pnpm -C plugins/a11y demo # dev: live WebSocket RPC +pnpm -C plugins/a11y cli:build # bake the static deploy (dist/static) +pnpm -C plugins/a11y demo:build # static: baked RPC dump, no server +``` + +Open the printed URL, then hover any row in the panel — the matching element in the page gets a focus ring and scrolls into view if it is off-screen. Both demo modes behave identically; the panel's `websocket` / `static` tag is the only tell. + +Run the panel on its own, without a host app: + +```sh +pnpm -C plugins/a11y dev +``` + +## Source + +[`plugins/a11y`](https://github.com/devframes/devframe/tree/main/plugins/a11y) diff --git a/docs/plugins/code-server.md b/docs/plugins/code-server.md new file mode 100644 index 0000000..ef48234 --- /dev/null +++ b/docs/plugins/code-server.md @@ -0,0 +1,65 @@ +--- +outline: deep +--- + +# Code Server + +Run [code-server](https://github.com/coder/code-server) (VS Code in the browser) as a devframe panel. The plugin detects a local install, launches it on demand, and embeds the editor in an auto-authenticated iframe. Its launcher UI is plain **vanilla TypeScript** — a state-driven view with no UI framework at all, which makes the framework-neutral point from the opposite end. + +Package: `@devframes/plugin-code-server` · framework: **Vanilla TypeScript** + +## What it does + +- **Detection** — on startup it runs `code-server --version`. When the binary is missing, the launcher renders install instructions instead of a launch button. +- **Launch** — the launcher's button starts code-server as a managed child process bound to a free port, scoped to the workspace, and probes readiness via its `/healthz` endpoint. +- **Auto-auth** — the plugin generates a random token, sets code-server's `HASHED_PASSWORD` to its SHA-256, and hands the matching session cookie back to the already-authorized devframe client, so the editor opens already signed in. + +The editor iframe points at code-server's own origin, so its WebSocket traffic flows directly without a reverse proxy. + +## Standalone + +```sh +npx @devframes/plugin-code-server +``` + +## Mount into a Vite host + +```ts +// vite.config.ts +import { codeServerVite } from '@devframes/plugin-code-server/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + codeServerVite(), + ], +}) +``` + +## Programmatic + +```ts +import { createCodeServerDevframe } from '@devframes/plugin-code-server' + +export default createCodeServerDevframe({ + // bin: 'code-server', // binary to detect / launch (default: PATH) + // serverPort: 8080, // force a port (default: free port near 8080) +}) +``` + +## RPC surface + +All functions are namespaced `devframes-plugin-code-server:*`: + +| Function | Type | Purpose | +|----------|------|---------| +| `detect` | `query` | Re-probe for the binary; returns `{ installed, version, bin }`. | +| `status` | `query` | Current status plus the auth cookie when running. | +| `start` | `action` | Launch and wait for readiness. | +| `stop` | `action` | Stop the process. | + +Status (minus the auth cookie) is mirrored into the `…:state` shared state for reactive UIs. + +## Source + +[`plugins/code-server`](https://github.com/devframes/devframe/tree/main/plugins/code-server) diff --git a/docs/plugins/git.md b/docs/plugins/git.md new file mode 100644 index 0000000..85e319e --- /dev/null +++ b/docs/plugins/git.md @@ -0,0 +1,53 @@ +--- +outline: deep +--- + +# Git + +A repository dashboard built as a **Next.js (App Router) + shadcn/ui** SPA over type-safe RPC. The host process shells out to `git` and exposes the repository; the same bundle runs as a live dev server or a fully static deployment, and follows the system light / dark preference. + +Package: `@devframes/plugin-git` · framework: **React (Next.js) + shadcn/ui** + +## What it does + +Status, a SourceTree-style commit graph, branches, and diffs are read-only. Staging, unstaging, and committing become available when write mode is enabled — and stay gated behind the repository's actual write permission in the UI. + +## Standalone + +```sh +npx @devframes/plugin-git # dev server (live RPC over WebSocket) +npx @devframes/plugin-git --write # also enable staging / committing from the UI +npx @devframes/plugin-git build # static deploy → dist-static/ +``` + +## Programmatic + +`createGitDevframe(options)` returns a definition you can mount into any host or drive yourself: + +```ts +import { createGitDevframe } from '@devframes/plugin-git' +import { createCli } from 'devframe/adapters/cli' + +await createCli(createGitDevframe({ repoRoot: process.cwd() })).parse() +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `repoRoot` | the devframe `cwd` | Repository directory to inspect. | +| `port` | `9710` | Preferred dev-server port. | +| `write` | `false` | Enable staging, unstaging, and committing from the UI. | + +## RPC surface + +The read functions are each a `query` with `snapshot: true` — resolved live over WebSocket in dev, and served from a snapshot baked at build time for static deploys. Each degrades to an empty, `isRepo: false` result outside a git repository. + +- `git:status` — branch, upstream tracking, and staged / unstaged / untracked files. +- `git:log` — paginated commit history including parent hashes, which drive the commit graph. +- `git:branches` — local branches with SHA, upstream, ahead / behind, and tip subject. +- `git:diff` — per-file added / deleted counts plus a unified patch for a selected file. + +Write actions (`git:stage`, `git:unstage`, `git:commit`) are `action` functions, registered only when write mode is enabled. + +## Source + +[`plugins/git`](https://github.com/devframes/devframe/tree/main/plugins/git) diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 0000000..c7e779d --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# Built-in Plugins + +Devframe ships a set of ready-to-run plugins. Each is a complete `DevframeDefinition` you can launch as a standalone CLI, mount into a Vite host, or dock inside a [hub](/guide/hub) — the same definition, deployed through any [adapter](/adapters/). + +Each plugin is built with a **different UI framework**. That is deliberate: devframe's client layer (`connectDevframe`, [RPC](/guide/rpc), and [shared state](/guide/shared-state)) is framework-neutral, so every plugin author picks whatever they like for the SPA. The collection doubles as living proof that devframe leaves the framework choice entirely to the author. + +| Plugin | UI framework | What it does | +|--------|--------------|--------------| +| [Devframe Inspector](./inspect) | Vue | Browse the RPC registry, invoke read-only queries, watch shared state update live, and explore the agent surface. | +| [Accessibility Inspector](./a11y) | Solid | Run axe-core against a host app, list WCAG violations, and highlight the offending element in the page on hover. | +| [Git](./git) | React (Next.js) | A repository dashboard — status, a commit graph, branches, and diffs, with optional staging and committing. | +| [Terminals](./terminals) | Svelte | Stream read-only command output and run fully interactive PTY shells in the browser. | +| [Code Server](./code-server) | Vanilla TypeScript | Launch code-server (VS Code in the browser) on demand and embed it in an auto-authenticated iframe. | + +## One client, any framework + +The five plugins span Vue, Solid, React, Svelte, and framework-free TypeScript, yet they share the same node-side surface: register RPC functions, publish shared state, and connect from the browser with `connectDevframe`. Whatever renders the UI — a reactive framework or a handful of DOM calls — talks to the backend through the same protocol. + +This is the framework-agnostic promise in practice. The browser bundle is the author's to choose; devframe handles the transport, the data model, the adapters, and the agent surface underneath. + +## Running a plugin + +Most plugins publish a `bin`, so the quickest path is `npx`: + +```sh +npx @devframes/plugin-inspect # the Devframe Inspector, standalone +npx @devframes/plugin-git # the Git dashboard against the current repo +``` + +Each also exports a `create…Devframe` factory (or, for the Accessibility Inspector, a ready-made definition) you can drive through any adapter — see the individual pages for the factory name, options, and host-mount snippets. diff --git a/docs/plugins/inspect.md b/docs/plugins/inspect.md new file mode 100644 index 0000000..ddf68ea --- /dev/null +++ b/docs/plugins/inspect.md @@ -0,0 +1,66 @@ +--- +outline: deep +--- + +# Devframe Inspector + +A self-inspector for any devframe connection, built as a **Vue** SPA. It browses the RPC registry, invokes read-only functions and shows their results, watches shared-state keys update live, and explores the agent-exposed surface — including the host's, when mounted into one. + +Package: `@devframes/plugin-inspect` · framework: **Vue + Vite** + +## What it does + +- **Functions** — every registered RPC function with its type, JSON-serializable / snapshot flags, args and return JSON Schema, and agent exposure. Read-only `query` / `static` functions can be invoked inline and their result envelope inspected. +- **State** — the keys of every shared-state entry, with a live JSON tree that flashes the paths that change as patches arrive. +- **Agent** — the agent manifest: the tools and readable resources the devframe exposes to coding agents. +- **History** — a recordable timeline of RPC calls and shared-state updates observed over the connection. + +The three introspection `query` functions are agent-exposed and bake into the static dump, so the inspector still lists functions, state keys, and the agent surface when deployed as a static SPA. + +## Standalone + +```sh +npx @devframes/plugin-inspect +``` + +Opens the inspector against a fresh standalone devframe connection — handy as a reference and for poking at the introspection RPCs themselves. The CLI prints the URL it serves on. + +## Mount into a Vite host + +```ts +// vite.config.ts +import { inspectVitePlugin } from '@devframes/plugin-inspect/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + inspectVitePlugin(), + ], +}) +``` + +## Programmatic + +`createInspectDevframe(options)` returns a definition you can deploy through any adapter: + +```ts +import { createInspectDevframe } from '@devframes/plugin-inspect' +import { createCli } from 'devframe/adapters/cli' + +await createCli(createInspectDevframe({ port: 9100 })).parse() +``` + +## RPC surface + +All functions are namespaced `devframes-plugin-inspect:*`: + +| Function | Type | Returns | +|----------|------|---------| +| `list-functions` | `query` (snapshot) | Every registered RPC function with metadata. | +| `invoke` | `action` | Invokes a read-only `query` / `static` function and returns a result envelope; refuses `action` / `event` functions. | +| `list-state-keys` | `query` (snapshot) | The keys of every shared-state entry. | +| `describe-agent` | `query` (snapshot) | The agent manifest — tools and readable resources. | + +## Source + +[`plugins/inspect`](https://github.com/devframes/devframe/tree/main/plugins/inspect) diff --git a/docs/plugins/terminals.md b/docs/plugins/terminals.md new file mode 100644 index 0000000..16f21b9 --- /dev/null +++ b/docs/plugins/terminals.md @@ -0,0 +1,55 @@ +--- +outline: deep +--- + +# Terminals + +A portable terminal panel built as a **Svelte** SPA on top of [xterm.js](https://xtermjs.org/). It streams read-only command output and runs fully interactive PTY shells — TUI-capable — in the browser. The same definition runs standalone, mounts into a Vite host, or docks inside a hub. + +Package: `@devframes/plugin-terminals` · framework: **Svelte + xterm.js** + +## What it does + +- **Read-only output** — stream the output of a command into a terminal view via devframe's [streaming channels](/guide/streaming). +- **Interactive shells** — spawn PTY-backed sessions you can type into, including full-screen TUI programs. Sessions can be renamed, resized, restarted, and removed; the session list lives in shared state so every panel stays in sync. +- **Presets** — declare named commands the user can launch with one click. + +Interactive shells require the optional `node-pty` peer; without it the panel still streams read-only output. + +## Standalone + +```sh +npx @devframes/plugin-terminals +``` + +## Mount into a Vite host + +```ts +// vite.config.ts +import { terminalsVite } from '@devframes/plugin-terminals/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + terminalsVite(), + ], +}) +``` + +## Programmatic + +`createTerminalsDevframe(options)` returns a definition you can deploy through any adapter. Declare presets to seed the launcher: + +```ts +import { createTerminalsDevframe } from '@devframes/plugin-terminals' + +export default createTerminalsDevframe({ + presets: [ + { id: 'dev', title: 'pnpm dev', command: 'pnpm', args: ['dev'] }, + ], +}) +``` + +## Source + +[`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals) diff --git a/examples/files-inspector/package.json b/examples/files-inspector/package.json index e06d10f..72a403b 100644 --- a/examples/files-inspector/package.json +++ b/examples/files-inspector/package.json @@ -16,6 +16,7 @@ "test": "vitest run" }, "dependencies": { + "@internal/design": "workspace:*", "devframe": "workspace:*", "preact": "catalog:frontend", "tinyglobby": "catalog:deps" @@ -24,6 +25,7 @@ "@preact/preset-vite": "catalog:build", "get-port-please": "catalog:deps", "h3": "catalog:deps", + "unocss": "catalog:frontend", "vite": "catalog:build", "vitest": "catalog:testing", "ws": "catalog:deps" diff --git a/examples/files-inspector/src/client/app.tsx b/examples/files-inspector/src/client/app.tsx index 3fb9d21..3d1f21f 100644 --- a/examples/files-inspector/src/client/app.tsx +++ b/examples/files-inspector/src/client/app.tsx @@ -1,4 +1,5 @@ import type { DevframeScopedClientContext } from 'devframe/client' +import { nav, navBrand, tab as tabClass, tabsList } from '@internal/design/components' import { connectDevframe } from 'devframe/client' import { useEffect, useState } from 'preact/hooks' import { About } from './routes/about' @@ -7,6 +8,11 @@ import { Home } from './routes/home' const NAMESPACE = 'devframe-files-inspector' export type InspectorCtx = DevframeScopedClientContext +const NAV_ITEMS = [ + { route: '/', label: 'Home', icon: 'i-ph-house-duotone' }, + { route: '/about', label: 'About', icon: 'i-ph-info-duotone' }, +] as const + function getBasePath(): string { return new URL(document.baseURI).pathname } @@ -44,48 +50,58 @@ export function App() { setRoute(to) } - if (!ctx) - return

Connecting to devframe…

+ if (!ctx) { + return ( +
+ Connecting to devframe… +
+ ) + } + + // Any non-/about route resolves to Home, mirroring the route switch below. + const active = route === '/about' ? '/about' : '/' return ( -
-
-

Files Inspector

-
+ +
+ {active === '/about' + ? + : } +
+ ) } diff --git a/examples/files-inspector/src/client/main.tsx b/examples/files-inspector/src/client/main.tsx index 88207af..332ca04 100644 --- a/examples/files-inspector/src/client/main.tsx +++ b/examples/files-inspector/src/client/main.tsx @@ -1,5 +1,17 @@ import { render } from 'preact' import { App } from './app' +import 'virtual:uno.css' +import '@internal/design/theme.css' + +// Shared design tokens flip on the `.dark` class; mirror the OS preference onto +// (the built-in devframe plugins follow the same approach). +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(d: boolean) { + document.documentElement.classList.toggle('dark', d) + document.documentElement.classList.toggle('light', !d) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) const root = document.getElementById('app') if (!root) diff --git a/examples/files-inspector/src/client/routes/about.tsx b/examples/files-inspector/src/client/routes/about.tsx index 39897c7..831f895 100644 --- a/examples/files-inspector/src/client/routes/about.tsx +++ b/examples/files-inspector/src/client/routes/about.tsx @@ -10,20 +10,35 @@ export function About({ ctx, basePath }: { ctx: InspectorCtx, basePath: string } }) }, [ctx]) + const rows = [ + { label: 'Resolved base path', value: basePath, icon: 'i-ph-path-duotone' }, + { label: 'Server cwd', value: cwd || '…', icon: 'i-ph-folder-duotone' }, + { label: 'RPC backend', value: ctx.base.connectionMeta.backend, icon: 'i-ph-plugs-connected-duotone' }, + ] + return ( -
-

About

-

- This page demonstrates that the SPA discovers its mount path at - runtime — the same bundle works under any base path. +

+
+ +

About

+
+ +

+ This page demonstrates that the SPA discovers its mount path at runtime — + the same bundle works under any base path.

-
-
Resolved base path
-
{basePath}
-
Server cwd
-
{cwd || '…'}
-
RPC backend
-
{ctx.base.connectionMeta.backend}
+ +
+ {rows.map(({ label, value, icon }) => ( +
+ +
{label}
+
{value}
+
+ ))}
) diff --git a/examples/files-inspector/src/client/routes/home.tsx b/examples/files-inspector/src/client/routes/home.tsx index fe0cc37..670d76f 100644 --- a/examples/files-inspector/src/client/routes/home.tsx +++ b/examples/files-inspector/src/client/routes/home.tsx @@ -1,4 +1,5 @@ import type { InspectorCtx } from '../app' +import { badge, button } from '@internal/design/components' import { useEffect, useState } from 'preact/hooks' export function Home({ ctx }: { ctx: InspectorCtx }) { @@ -22,22 +23,46 @@ export function Home({ ctx }: { ctx: InspectorCtx }) { }, []) return ( -
-

- Files - {' '} - - ( +
+
+ +

Files

+ {files.length} - ) -
-

- -
    - {files.map(f =>
  • {f}
  • )} -
+ + + + + +
+ {files.length === 0 + ? ( +

+ {loading ? 'Loading files…' : 'No files in the working directory.'} +

+ ) + : ( +
    + {files.map(f => ( +
  • + + {f} +
  • + ))} +
+ )} +
) } diff --git a/examples/files-inspector/src/client/shims.d.ts b/examples/files-inspector/src/client/shims.d.ts new file mode 100644 index 0000000..4230192 --- /dev/null +++ b/examples/files-inspector/src/client/shims.d.ts @@ -0,0 +1,3 @@ +// Side-effect style imports used by the Preact SPA entry. +declare module '*.css' {} +declare module 'virtual:uno.css' {} diff --git a/examples/files-inspector/src/client/vite.config.ts b/examples/files-inspector/src/client/vite.config.ts index e19bcad..d542a1b 100644 --- a/examples/files-inspector/src/client/vite.config.ts +++ b/examples/files-inspector/src/client/vite.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import preact from '@preact/preset-vite' +import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' import { alias } from '../../../../alias' @@ -7,7 +8,7 @@ export default defineConfig({ base: './', root: fileURLToPath(new URL('.', import.meta.url)), resolve: { alias }, - plugins: [preact()], + plugins: [UnoCSS(), preact()], build: { outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), emptyOutDir: true, diff --git a/examples/files-inspector/uno.config.ts b/examples/files-inspector/uno.config.ts new file mode 100644 index 0000000..a838e1c --- /dev/null +++ b/examples/files-inspector/uno.config.ts @@ -0,0 +1,9 @@ +import { presetDevframe } from '@internal/design/preset' +import { defineConfig } from 'unocss' + +// This example's Preact SPA extends the shared devframe design system for its +// tokens, `df-*` vocabulary and Phosphor icons — matching the built-in plugins. +export default defineConfig({ + presets: [presetDevframe()], + content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } }, +}) 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..56ef7a0 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/hub": "workspace:*", + "@devframes/plugin-a11y": "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

- +
+ -
- -
+
+ +
+
-