diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 293f9767..cae05031 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -153,3 +153,37 @@ The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client scri ## Iframe panels Dock iframes are their own documents, so they connect themselves instead of reading the host page's context: the panel SPA calls `connectDevframe()`, which discovers `./__connection.json` relative to its own base — `mountDevframe` serves the hub's connection meta under every dock base for exactly this. The client script (host page) and the iframe panel then share the server through RPC and shared state, or a same-origin `BroadcastChannel` when the loop must survive static builds. + +## Shared-iframe soft navigation + +A tool with many internal views — Nuxt DevTools' tabs, say — can surface each view as its own hub dock while they all share **one** live iframe, switching between them with client-side (soft) navigation instead of reloading. One iframe dock is the **anchor**: it owns a `frameId` and opts in with `subTabs`. + +```ts +await mountDevframe(ctx, nuxtDevtools, { + dock: { frameId: 'nuxt-devtools', subTabs: { protocol: 'postmessage' } }, +}) +``` + +When the anchor's iframe mounts, the client host attaches a **frame-nav adapter** that speaks a small, versioned, origin-locked `postMessage` protocol with the embedded app. The app ships a ~40-line shim on the `devframe:frame-nav` channel: + +| Message | Direction | Meaning | +|---|---|---| +| `ready` / `manifest` | frame → host | the current tab list (`{ tabs, current }`), on load and whenever it changes | +| `navigate` | host → frame | show this view (`{ tabId, navTarget }`) — the app routes client-side | +| `navigated` | frame → host | the app navigated internally, so the host highlights the matching dock | + +The adapter materializes one [client-only dock](#client-only-docks) per reported tab (id `:`), each sharing the anchor's `frameId` and carrying a `navTarget`. Selecting a member soft-navigates the shared iframe; navigating inside the iframe moves the hub's active dock — the loop runs both ways with an idempotent guard against echoes. The embedded app needs no hub or RPC dependency, only the shim; a plain iframe with no shim simply stays a single dock. + +`frameId` is independent of [`groupId`](./hub#grouping-dock-entries): members sharing one iframe may live in a group, several groups, or none. + +### The viewer's part + +A viewer keeps one iframe alive per `frameId` (shown/hidden across switches, never re-`src`'d) and, when it mounts that element, sets it on the anchor's dock state and announces it: + +```ts +const state = ctx.docks.getStateById(anchorId)! +state.domElements.iframe = iframeEl +state.events.emit('dom:iframe:mounted', iframeEl) +``` + +That announcement is what the adapter attaches to. Both minimal hubs wire this end to end — see the "Tabbed Tool" in [`examples/minimal-vite-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) and [`examples/minimal-next-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub), including the SPA's `postMessage` shim. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index cda6ef71..4a588be9 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -199,6 +199,8 @@ ctx.docks.register({ `groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated. +Grouping is about the dock bar; it does not share an iframe. When several iframe docks should render into **one** live iframe and switch views by soft navigation — a multi-tab tool like Nuxt DevTools hosted as first-class docks — give them a shared `frameId` and mark the anchor with `subTabs`. `frameId` is an axis independent of `groupId`, so shared-iframe members may sit in a group, across groups, or ungrouped. See [Shared-iframe soft navigation](./client-context#shared-iframe-soft-navigation). + ### The dual role of `category` `category` decides an entry's outer bucket on the dock bar (ordered by `DEFAULT_CATEGORIES_ORDER`) — but its meaning shifts once an entry joins a group: @@ -261,6 +263,8 @@ Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-ser - [`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. +Both also mount a "Tabbed Tool" that demonstrates [shared-iframe soft navigation](./client-context#shared-iframe-soft-navigation) — one SPA whose tabs surface as separate docks sharing a single iframe. + 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/spa/next-tabbed-tool/index.html b/examples/minimal-next-devframe-hub/spa/next-tabbed-tool/index.html new file mode 100644 index 00000000..f4e88e87 --- /dev/null +++ b/examples/minimal-next-devframe-hub/spa/next-tabbed-tool/index.html @@ -0,0 +1,175 @@ + + + + + + Next Tabbed Tool + + + +
+ Next Tabbed Tool + +
+
+
+

Overview

+

+ One iframe, many hub docks. This SPA hosts its own internal views + (Overview · Components · Timeline · Settings) + and exposes each as a first-class hub dock that shares + this single iframe. Switching docks soft-navigates here — no + reload — via the devframe:frame-nav postMessage protocol. +

+
+
Shared frameframeId · next-tabbed-tool
+
Transportwindow.postMessage
+
Docksclient-only, from manifest
+
Navbidirectional
+
+
+ +
+

Components

+

A stand-in view. Selecting the Components dock in the hub posts + navigate to this frame, which routes here client-side.

+
+
<Button />12 usages
+
<Dialog />4 usages
+
<Tabs />7 usages
+
+
+ +
+

Timeline

+

Navigate inside this frame (the tab bar above) and watch the + hub's active dock follow — the frame posts navigated back.

+
    +
  1. 10:02 — build started
  2. +
  3. 10:02 — 214 modules transformed
  4. +
  5. 10:03 — page reload
  6. +
+
+ +
+

Settings

+

The manifest each tab reports (title, icon, + navTarget) becomes a client-only dock in the hub — never + written to the devframe:docks shared state.

+
+
Themefollows OS
+
Telemetryoff
+
+
+
+ + + + diff --git a/examples/minimal-next-devframe-hub/src/client/app/icons.ts b/examples/minimal-next-devframe-hub/src/client/app/icons.ts index b79a4658..be6d4885 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/icons.ts +++ b/examples/minimal-next-devframe-hub/src/client/app/icons.ts @@ -13,6 +13,14 @@ const ICON_CLASS: Record = { 'ph:rocket-duotone': 'i-ph-rocket-duotone', 'ph:wrench-duotone': 'i-ph-wrench-duotone', 'ph:plug-duotone': 'i-ph-plug-duotone', + 'ph:layout-duotone': 'i-ph-layout-duotone', + 'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone', + 'ph:squares-four-duotone': 'i-ph-squares-four-duotone', + 'ph:house-duotone': 'i-ph-house-duotone', + 'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone', + 'ph:clock-duotone': 'i-ph-clock-duotone', + 'ph:gear-duotone': 'i-ph-gear-duotone', + 'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone', } /** diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index a93959d8..722c0e08 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -10,7 +10,7 @@ import type { } from '@devframes/hub/types' import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' -import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client' import { useEffect, useMemo, useRef, useState } from 'react' import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' import { iconClass } from './icons' @@ -37,6 +37,13 @@ function isRenderableDock(d: DevframeDockEntry): boolean { return isIframeDock(d) || RENDERER_TYPES.has(d.type) } +// One iframe is kept alive per `frameId` (shared-frame docks) or per dock id +// (plain iframe docks), so shared-frame member docks reuse the same element and +// soft-navigate rather than reload. +function frameKeyOf(e: DevframeDockEntry): string { + return (e as DevframeViewIframe).frameId ?? e.id +} + // A self-contained document for the client-only dock, rendered from a Blob URL // so the whole dock is synthesized in the browser with no server route. function createClientNotesUrl(): string { @@ -150,7 +157,12 @@ export default function Page() { const [selectedDockId, setSelectedDockId] = useState(null) const rpcRef = useRef(null) const hostRef = useRef(null) + // The stage holds the kept-alive iframe pool; the panel hosts renderer docks. + const stageRef = useRef(null) const panelRef = useRef(null) + const iframePoolRef = useRef>(new Map()) + const rendererMountRef = useRef<{ id: string, dispose: () => void } | null>(null) + const wiredRef = useRef>(new Set()) useEffect(() => { let cancelled = false @@ -176,15 +188,16 @@ export default function Page() { renderers: { 'json-render': createReactJsonRenderDockRenderer() }, }) hostRef.current = clientHost + const ctx = clientHost.context // Register a *client-only* dock — one this page synthesizes itself. // Unlike the server-authored docks, it's registered on the client host // context, so it never enters the `devframe:docks` shared state: it // stays local to this page and is not synced to the hub server or other - // viewers. It merges into `clientHost.context.docks.entries` alongside - // the server docks. `force` lets React StrictMode re-run this effect - // without tripping the duplicate-id guard. - const clientDock = clientHost.context.docks.register({ + // viewers. It merges into `ctx.docks.entries` alongside the server + // docks. `force` lets React StrictMode re-run this effect without + // tripping the duplicate-id guard. + const clientDock = ctx.docks.register({ id: 'client-notes', title: 'Client Notes', icon: 'ph:note-pencil-duotone', @@ -193,7 +206,7 @@ export default function Page() { category: 'app', }, true) // Patch it in place with the returned handle (the id is immutable). - clientDock.update({ badge: clientHost.context.clientType }) + clientDock.update({ badge: ctx.clientType }) // Register a second client-only dock — this one a *json-render* view the // page authors itself, the richer sibling of the iframe dock above. Its @@ -221,16 +234,33 @@ export default function Page() { { initialValue: [] }, ) - // The merged list from the client host: server docks (projected from - // `devframe:docks` shared state) plus the client-only dock above. We - // still subscribe to the shared state to re-render on server changes. - const renderDocks = () => setDocks([...clientHost.context.docks.entries]) + // Mirror the merged dock list (server docks + client-only docks) and the + // host's current selection into React state. Selection is owned by the + // client host (`switchEntry`) — that is what lets the frame-nav adapter + // hear a dock's `entry:activated` and drive shared-frame soft-navigation. + const syncDocks = () => setDocks([...ctx.docks.entries]) + const syncSelected = () => setSelectedDockId(ctx.docks.selectedId) const renderCommands = () => setCommands([...(commandsState.value() ?? [])] as DevframeCommandEntry[]) - docksState.on('updated', renderDocks) + docksState.on('updated', syncDocks) commandsState.on('updated', renderCommands) - renderDocks() + syncDocks() + syncSelected() renderCommands() + // The frame-nav adapter registers/updates client-only member docks in + // response to a shared-frame anchor's manifest; re-sync (after it has + // reconciled, hence the microtask) so those docks appear in the list. + const onMessage = (event: MessageEvent) => { + const data = event.data as { channel?: string, from?: string } | undefined + if (data?.channel === FRAME_NAV_CHANNEL && data.from === 'frame') { + queueMicrotask(() => { + syncDocks() + syncSelected() + }) + } + } + window.addEventListener('message', onMessage) + const refreshMessages = async () => { const entries = await rpc.call( 'minimal-next-devframe-hub:messages:list' as any, @@ -257,10 +287,16 @@ export default function Page() { cleanup = () => { window.clearInterval(interval) - // Remove the client-only docks, then tear down the host. + window.removeEventListener('message', onMessage) + // Remove the client-only docks, then tear down the host + local DOM. clientDock.dispose() clientJsonRenderDock.dispose() clientHost.dispose() + wiredRef.current.clear() + for (const el of iframePoolRef.current.values()) el.remove() + iframePoolRef.current.clear() + rendererMountRef.current?.dispose() + rendererMountRef.current = null } } catch (err) { @@ -280,21 +316,71 @@ export default function Page() { const renderableDocks = useMemo(() => docks.filter(isRenderableDock), [docks]) + // Wire each dock's state once so a selection change — from a click, or from + // the frame-nav adapter reacting to in-frame navigation — updates the UI. useEffect(() => { - if (selectedDockId && !renderableDocks.some(d => d.id === selectedDockId)) { - setSelectedDockId(null) + const ctx = hostRef.current?.context + if (!ctx) return + for (const entry of docks) { + if (wiredRef.current.has(entry.id)) + continue + const state = ctx.docks.getStateById(entry.id) + if (!state) + continue + wiredRef.current.add(entry.id) + state.events.on('entry:activated', () => setSelectedDockId(ctx.docks.selectedId)) } + }, [docks]) + + // Drive selection through the client host, and auto-select the first dock. + useEffect(() => { + const ctx = hostRef.current?.context + if (!ctx) + return if (!selectedDockId && renderableDocks.length > 0) - setSelectedDockId(renderableDocks[0].id) + void ctx.docks.switchEntry(renderableDocks[0].id) }, [renderableDocks, selectedDockId]) const selectedDock = renderableDocks.find(d => d.id === selectedDockId) ?? null const selectedIsIframe = selectedDock ? isIframeDock(selectedDock) : false + // Keep-alive iframe pool: ensure + show the iframe for the selected dock's + // frame, hide the rest. Creating an iframe hands it to the client host + // (`domElements.iframe` + `dom:iframe:mounted`) so the frame-nav adapter can + // attach to a `subTabs` anchor (plan §6.2). + useEffect(() => { + const ctx = hostRef.current?.context + const stage = stageRef.current + if (!ctx || !stage) + return + const pool = iframePoolRef.current + + if (selectedDock && isIframeDock(selectedDock)) { + const key = frameKeyOf(selectedDock) + let el = pool.get(key) + if (!el) { + el = document.createElement('iframe') + el.title = selectedDock.title + el.className = 'absolute inset-0 block h-full w-full border-0 bg-base' + el.src = selectedDock.url + stage.appendChild(el) + pool.set(key, el) + const state = ctx.docks.getStateById(selectedDock.id) + if (state) { + state.domElements.iframe = el + state.events.emit('dom:iframe:mounted', el) + } + } + for (const other of pool.values()) other.hidden = other !== el + } + else { + for (const other of pool.values()) other.hidden = true + } + }, [selectedDockId, docks, selectedDock]) + // Mount a renderer dock (json-render) into the panel via the client host's - // renderer registry, disposing when the selection changes. Keyed by dock id - // so a live view-state update (its own shared state) doesn't remount. + // renderer registry, disposing when the selection changes. useEffect(() => { const host = hostRef.current const dock = selectedDock @@ -356,7 +442,7 @@ export default function Page() {
  • `) + `
  • `) - void applySelection(list) + void showSelection(list) } docksEl.addEventListener('click', (event) => { @@ -278,14 +324,21 @@ async function main() { if (!target) return const id = target.dataset.dockId - if (!id || id === selectedDockId) + if (!id || id === docksCtx.selectedId) return - selectedDockId = id - renderDocks() + void docksCtx.switchEntry(id) }) - docks.on('updated', renderDocks) - renderDocks() + docks.on('updated', render) + // The frame-nav adapter registers/updates client-only member docks in + // response to the anchor iframe's manifest; re-render (after the adapter has + // reconciled, hence the microtask) so those docks appear in the list. + window.addEventListener('message', (event) => { + const data = event.data as { channel?: string, from?: string } | undefined + if (data?.channel === FRAME_NAV_CHANNEL && data.from === 'frame') + queueMicrotask(render) + }) + render() // 2. Commands — read from `devframe:commands` shared state. const commands = await rpc.sharedState.get( diff --git a/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts b/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts new file mode 100644 index 00000000..01a165da --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts @@ -0,0 +1,35 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } + +/** + * A demo of **shared-iframe soft navigation**. This devframe is a single SPA + * with several internal views (Overview / Components / Timeline / Settings). + * Mounted as a `subTabs` anchor (see `vite.config.ts`), the hub's client host + * attaches its frame-nav adapter: the SPA's `postMessage` shim reports its tabs, + * each becomes a client-only hub dock sharing this one iframe, and switching + * docks soft-navigates inside it instead of reloading — a unified home for a + * multi-tab tool like Nuxt DevTools. + */ +export default defineDevframe({ + id: 'tabbed-tool', + name: 'Tabbed Tool', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: 'A multi-view SPA hosted as shared-iframe hub docks with soft navigation.', + icon: 'ph:squares-four-duotone', + basePath: '/__tabbed-tool/', + cli: { + distDir: fileURLToPath(new URL('../spa/tabbed-tool/', import.meta.url)), + }, + async setup(rawCtx) { + const ctx = rawCtx as unknown as DevframeHubContext + await ctx.messages.add({ + level: 'info', + message: 'Tabbed Tool mounted as a shared-iframe anchor', + description: 'Its tabs surface as client-only docks that soft-navigate one iframe.', + }) + }, +}) diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index 86a39bbe..d9a2223e 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,3 +1,4 @@ +import { mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' import codeServerDevframe from '@devframes/plugin-code-server' @@ -16,6 +17,7 @@ import { alias } from '../../alias' import demoDevframe from './src/devframe' import demoDevframeB from './src/devframe-b' import { minimalViteDevframeHub } from './src/minimal-vite-devframe-hub' +import tabbedToolDevframe from './src/tabbed-tool' export default defineConfig({ resolve: { alias }, @@ -81,7 +83,7 @@ export default defineConfig({ // Dogfood the opt-in JSON-render hub integration: author a view on the // hub context and project it onto a `json-render` dock. The client host // (src/client/main.ts) renders it via @devframes/json-render-ui. - onContextReady: (context) => { + onContextReady: async (context) => { const view = createDashboardView(context) context.docks.register(toJsonRenderDockEntry(view, { id: 'minimal-json-render', @@ -89,6 +91,19 @@ export default defineConfig({ icon: 'ph:layout-duotone', category: 'app', })) + + // Shared-iframe soft-navigation demo. mountDevframe serves the SPA and + // registers its iframe dock; the `dock` override marks it a `subTabs` + // anchor (a shared `frameId` + the postmessage protocol) so the client + // host attaches the frame-nav adapter, materializing one client-only + // dock per tab the SPA's shim reports — all sharing this one iframe. + await mountDevframe(context, tabbedToolDevframe, { + dock: { + category: 'app', + frameId: 'tabbed-tool', + subTabs: { protocol: 'postmessage' }, + }, + }) }, }), ], diff --git a/packages/hub/src/client/__tests__/frame-nav.test.ts b/packages/hub/src/client/__tests__/frame-nav.test.ts new file mode 100644 index 00000000..25e35ef6 --- /dev/null +++ b/packages/hub/src/client/__tests__/frame-nav.test.ts @@ -0,0 +1,241 @@ +import type { DevframeViewIframe } from '../../types/docks' +import type { DockEntryState, DocksEntriesContext } from '../docks' +import type { FrameTab } from '../frame-nav' +import { createEventEmitter } from 'devframe/utils/events' +import { describe, expect, it, vi } from 'vitest' +import { attachFrameNavClient, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION } from '../frame-nav' + +const ORIGIN = 'https://app.example' + +function anchorEntry(extra?: Partial): DevframeViewIframe { + return { + id: 'nuxt', + type: 'iframe', + title: 'Nuxt', + icon: 'i-logos:nuxt', + url: `${ORIGIN}/__nuxt/`, + frameId: 'nuxt', + subTabs: { protocol: 'postmessage' }, + groupId: 'nuxt-group', + ...extra, + } +} + +function createDocksStub() { + const entries = new Map() + const states = new Map() + let selectedId: string | null = null + + function makeState(id: string): DockEntryState { + return { + get entryMeta() { + return entries.get(id)! + }, + set entryMeta(_v) {}, + get isActive() { + return selectedId === id + }, + domElements: {}, + events: createEventEmitter(), + } as DockEntryState + } + + const docks: Pick = { + register(entry) { + entries.set(entry.id, entry as DevframeViewIframe) + states.set(entry.id, makeState(entry.id)) + return { + update: (patch) => { + entries.set(entry.id, { ...entries.get(entry.id)!, ...patch } as DevframeViewIframe) + }, + dispose: () => { + entries.delete(entry.id) + states.delete(entry.id) + if (selectedId === entry.id) + selectedId = null + }, + } + }, + getStateById: id => states.get(id), + async switchEntry(id) { + const next = id ?? null + if (next === selectedId) + return false + if (next !== null && !states.has(next)) + return false + const prev = selectedId + selectedId = next + if (prev) + states.get(prev)?.events.emit('entry:deactivated') + if (next) + states.get(next)?.events.emit('entry:activated') + return true + }, + } + + return { + docks, + entries, + get selectedId() { + return selectedId + }, + select: (id: string) => docks.switchEntry(id), + } +} + +function createWindowStub() { + const listeners = new Set<(ev: MessageEvent) => void>() + return { + target: { + addEventListener: (_t: 'message', l: (ev: MessageEvent) => void) => listeners.add(l), + removeEventListener: (_t: 'message', l: (ev: MessageEvent) => void) => listeners.delete(l), + }, + emitFrame(data: unknown, origin = ORIGIN) { + const ev = { data, origin } as MessageEvent + for (const l of [...listeners]) l(ev) + }, + count: () => listeners.size, + } +} + +function createIframeStub() { + const posted: Array<{ msg: any, origin: string }> = [] + const iframe = { + src: `${ORIGIN}/__nuxt/`, + contentWindow: { + postMessage: (msg: any, origin: string) => posted.push({ msg, origin }), + }, + } as unknown as Pick + return { iframe, posted } +} + +function frameMsg(type: string, extra?: Record) { + return { channel: FRAME_NAV_CHANNEL, v: FRAME_NAV_VERSION, frameId: 'nuxt', from: 'frame', type, ...extra } +} + +const TABS: FrameTab[] = [ + { id: 'modules', title: 'Modules', navTarget: { path: '/modules' } }, + { id: 'timeline', title: 'Timeline', navTarget: { path: '/timeline' } }, +] + +function boot(extra?: Partial) { + const docksStub = createDocksStub() + const win = createWindowStub() + const { iframe, posted } = createIframeStub() + const adapter = attachFrameNavClient({ + frameId: 'nuxt', + anchor: anchorEntry(extra), + iframe, + docks: docksStub.docks, + window: win.target, + }) + return { docksStub, win, iframe, posted, adapter } +} + +const navPosts = (posted: Array<{ msg: any }>) => posted.filter(p => p.msg.type === 'navigate') + +describe('attachFrameNavClient', () => { + it('greets the shim with a hello on the anchor origin', () => { + const { posted } = boot() + expect(posted).toHaveLength(1) + expect(posted[0].msg).toMatchObject({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'nuxt', from: 'host', type: 'hello' }) + expect(posted[0].origin).toBe(ORIGIN) + }) + + it('materializes client-only member docks from ready, selecting current without echoing navigate', () => { + const { docksStub, win, adapter, posted } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' })) + + expect(adapter.ready).toBe(true) + expect([...docksStub.entries.keys()]).toEqual(['nuxt:modules', 'nuxt:timeline']) + // Member inherits frameId, anchor icon + groupId, carries its own navTarget. + expect(docksStub.entries.get('nuxt:timeline')).toMatchObject({ + type: 'iframe', + frameId: 'nuxt', + icon: 'i-logos:nuxt', + groupId: 'nuxt-group', + navTarget: { path: '/timeline' }, + }) + // The initial `current` selects its dock but does not post a navigate. + expect(docksStub.selectedId).toBe('nuxt:modules') + expect(adapter.currentTabId).toBe('modules') + expect(navPosts(posted)).toHaveLength(0) + }) + + it('posts navigate when the user selects a member, guarding the echo back', async () => { + const { docksStub, win, adapter, posted } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' })) + + await docksStub.select('nuxt:timeline') + const navs = navPosts(posted) + expect(navs).toHaveLength(1) + expect(navs[0].msg).toMatchObject({ type: 'navigate', tabId: 'timeline', navTarget: { path: '/timeline' } }) + expect(adapter.currentTabId).toBe('timeline') + + // The shim's echoed `navigated` for the same tab must not re-post navigate. + win.emitFrame(frameMsg('navigated', { tabId: 'timeline' })) + expect(navPosts(posted)).toHaveLength(1) + expect(docksStub.selectedId).toBe('nuxt:timeline') + }) + + it('moves the dock highlight on internal navigation without posting navigate', () => { + const { docksStub, win, posted, adapter } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' })) + + win.emitFrame(frameMsg('navigated', { tabId: 'timeline' })) + expect(docksStub.selectedId).toBe('nuxt:timeline') + expect(adapter.currentTabId).toBe('timeline') + expect(navPosts(posted)).toHaveLength(0) + }) + + it('reconciles a manifest snapshot: removes a vanished tab and clears its selection', () => { + const { docksStub, win, adapter } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'timeline' })) + expect(docksStub.selectedId).toBe('nuxt:timeline') + + win.emitFrame(frameMsg('manifest', { tabs: [TABS[0]] })) + expect([...docksStub.entries.keys()]).toEqual(['nuxt:modules']) + expect(docksStub.selectedId).toBeNull() + expect(adapter.currentTabId).toBeNull() + }) + + it('ignores messages from a foreign origin', () => { + const { docksStub, win, adapter } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS }), 'https://evil.example') + expect(adapter.ready).toBe(false) + expect(docksStub.entries.size).toBe(0) + }) + + it('ignores malformed / mis-tagged envelopes', () => { + const { win, adapter } = boot() + win.emitFrame({ channel: 'other', v: 1, frameId: 'nuxt', from: 'frame', type: 'ready', tabs: TABS }) + win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 2, frameId: 'nuxt', from: 'frame', type: 'ready', tabs: TABS }) + win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'other', from: 'frame', type: 'ready', tabs: TABS }) + win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'nuxt', from: 'host', type: 'ready', tabs: TABS }) + expect(adapter.ready).toBe(false) + }) + + it('dispose detaches the listener and removes every member dock', () => { + const { docksStub, win, adapter } = boot() + win.emitFrame(frameMsg('ready', { tabs: TABS })) + expect(docksStub.entries.size).toBe(2) + expect(win.count()).toBe(1) + + adapter.dispose() + expect(win.count()).toBe(0) + expect(docksStub.entries.size).toBe(0) + }) + + it('arms a handshake timeout that leaves the adapter un-ready with no shim', () => { + vi.useFakeTimers() + try { + const { adapter, docksStub } = boot() + vi.advanceTimersByTime(3000) + expect(adapter.ready).toBe(false) + expect(docksStub.entries.size).toBe(0) + } + finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/hub/src/client/frame-nav.ts b/packages/hub/src/client/frame-nav.ts new file mode 100644 index 00000000..f44b7dc0 --- /dev/null +++ b/packages/hub/src/client/frame-nav.ts @@ -0,0 +1,352 @@ +import type { DevframeDockEntryIcon, DevframeViewIframe, NavTarget } from '../types/docks' +import type { DockRegistration, DocksEntriesContext } from './docks' + +/** + * Shared-iframe soft navigation — the viewer-side half of a host↔iframe + * `postMessage` protocol. + * + * An {@link DevframeViewIframe.subTabs anchor} iframe dock owns one live iframe + * (its {@link DevframeViewIframe.frameId frameId}); the embedded app ships a + * tiny **nav shim** that announces its tabs and answers navigation over + * `postMessage`. This adapter, auto-attached by the client host when the anchor + * iframe mounts, runs the ready handshake, turns the reported tab manifest into + * **client-only** member docks (via {@link DocksEntriesContext.register}), and + * drives the bidirectional nav loop: + * + * - selecting a member dock posts a `navigate` (client-side, no reload); + * - the app posts `navigated` on any internal navigation, moving the dock + * highlight to match. + * + * The protocol is server-free (works cross-origin and in static builds) and + * decoupled — the embedded app takes no hub/RPC dependency, only the shim. + */ + +/** `postMessage` channel tag shared by both halves of the protocol. */ +export const FRAME_NAV_CHANNEL = 'devframe:frame-nav' +/** Protocol version. */ +export const FRAME_NAV_VERSION = 1 + +/** Envelope every frame-nav message carries. */ +export interface FrameNavEnvelope { + channel: typeof FRAME_NAV_CHANNEL + v: typeof FRAME_NAV_VERSION + frameId: string + /** Direction tag; each side ignores messages with its own `from`. */ + from: 'host' | 'frame' +} + +/** + * One tab in the manifest the shim reports. Maps directly onto a dock entry, so + * a materialized member dock is first-class. + */ +export interface FrameTab { + /** Unique within the frame. The member dock id becomes `:`. */ + id: string + title: string + icon?: DevframeDockEntryIcon + /** Soft-nav target the app maps to its own router. */ + navTarget: NavTarget + /** + * Absolute or anchor-relative URL used for the boot deep-link and the + * hard-nav fallback. When omitted, one is derived from the anchor URL + + * `navTarget.path` (hash routing). + */ + fallbackUrl?: string + badge?: string + order?: number + category?: string + when?: string + groupId?: string +} + +/** Host → frame message payloads (without the {@link FrameNavEnvelope}). */ +export type FrameNavHostPayload + = | { type: 'hello' } + | { type: 'navigate', tabId: string, navTarget: NavTarget } + +/** Host → frame messages. */ +export type FrameNavHostMessage = FrameNavEnvelope & { from: 'host' } & FrameNavHostPayload + +/** Frame → host messages. */ +export type FrameNavFrameMessage = FrameNavEnvelope & { from: 'frame' } & ( + | { type: 'ready', tabs: FrameTab[], current?: string } + | { type: 'manifest', tabs: FrameTab[], current?: string } + | { type: 'navigated', tabId?: string, navTarget?: NavTarget } +) + +/** Minimal window surface the adapter listens on (injectable for tests). */ +export interface FrameNavListenTarget { + addEventListener: (type: 'message', listener: (ev: MessageEvent) => void) => void + removeEventListener: (type: 'message', listener: (ev: MessageEvent) => void) => void +} + +export interface FrameNavClientOptions { + /** The shared frame id (defaults to the anchor's, else the anchor id). */ + frameId: string + /** The anchor iframe dock entry — supplies url/icon/groupId defaults. */ + anchor: DevframeViewIframe + /** The live iframe element hosting the embedded app. */ + iframe: Pick + /** Client docks context — to register members and drive selection. */ + docks: Pick + /** + * Window to receive the frame's `message` events on (the host page window). + * Defaults to `globalThis`. + */ + window?: FrameNavListenTarget + /** Expected origin for `postMessage`; derived from the anchor URL otherwise. */ + origin?: string + /** @default 3000 */ + handshakeTimeoutMs?: number +} + +export interface FrameNavClient { + /** Whether the shim has completed its `ready` handshake. */ + readonly ready: boolean + /** The tab id currently shown in the frame, or `null`. */ + readonly currentTabId: string | null + dispose: () => void +} + +interface MemberRecord { + tab: FrameTab + handle: DockRegistration + off?: () => void +} + +/** + * Attach the frame-nav adapter to a mounted anchor iframe. Returns a handle + * exposing readiness/current state and a `dispose()` that removes every member + * dock it registered and detaches its listener. + */ +export function attachFrameNavClient(options: FrameNavClientOptions): FrameNavClient { + const { frameId, anchor, iframe, docks } = options + const listenTarget: FrameNavListenTarget = options.window ?? (globalThis as unknown as FrameNavListenTarget) + const expectedOrigin = options.origin ?? resolveOrigin(anchor.url) + const timeoutMs = options.handshakeTimeoutMs ?? anchor.subTabs?.handshakeTimeoutMs ?? 3000 + + const members = new Map() + let ready = false + let currentTabId: string | null = null + let disposed = false + let handshakeTimer: ReturnType | undefined + + function post(message: FrameNavHostPayload): void { + iframe.contentWindow?.postMessage( + { channel: FRAME_NAV_CHANNEL, v: FRAME_NAV_VERSION, frameId, from: 'host', ...message }, + expectedOrigin, + ) + } + + function onMessage(ev: MessageEvent): void { + if (disposed) + return + // Origin-lock (skipped only when we could not resolve one — dev fallback). + if (expectedOrigin !== '*' && ev.origin !== expectedOrigin) + return + const data = ev.data as Partial | undefined + if ( + !data + || data.channel !== FRAME_NAV_CHANNEL + || data.v !== FRAME_NAV_VERSION + || data.frameId !== frameId + || data.from !== 'frame' + ) { + return + } + switch (data.type) { + case 'ready': + case 'manifest': + markReady() + reconcileManifest(data.tabs ?? [], data.current) + break + case 'navigated': + onNavigated(data.tabId) + break + } + } + + function markReady(): void { + ready = true + if (handshakeTimer !== undefined) { + clearTimeout(handshakeTimer) + handshakeTimer = undefined + } + } + + function memberId(tabId: string): string { + return `${frameId}:${tabId}` + } + + function buildMemberEntry(tab: FrameTab): DevframeViewIframe { + const entry: DevframeViewIframe = { + id: memberId(tab.id), + type: 'iframe', + title: tab.title, + icon: tab.icon ?? anchor.icon, + frameId, + url: tab.fallbackUrl ?? deriveFallbackUrl(anchor.url, tab.navTarget), + navTarget: tab.navTarget, + } + if (tab.badge !== undefined) + entry.badge = tab.badge + if (tab.order !== undefined) + entry.defaultOrder = tab.order + if (tab.category !== undefined) + entry.category = tab.category + if (tab.when !== undefined) + entry.when = tab.when + const groupId = tab.groupId ?? anchor.groupId + if (groupId !== undefined) + entry.groupId = groupId + return entry + } + + function reconcileManifest(tabs: FrameTab[], current?: string): void { + const incoming = new Set(tabs.map(t => t.id)) + + for (const tab of tabs) { + const entry = buildMemberEntry(tab) + const existing = members.get(tab.id) + if (existing) { + existing.tab = tab + existing.handle.update(entry) + } + else { + const handle = docks.register(entry) as DockRegistration + const record: MemberRecord = { tab, handle } + // Selecting the member dock in the viewer drives a soft-nav. + record.off = docks + .getStateById(entry.id) + ?.events + .on('entry:activated', () => onMemberActivated(tab.id)) + members.set(tab.id, record) + } + } + + for (const [tabId, record] of [...members]) { + if (incoming.has(tabId)) + continue + record.off?.() + record.handle.dispose() + members.delete(tabId) + // Removing the active member clears selection (host reconcile drops the + // selected id when its entry disappears); keep our mirror consistent. + if (currentTabId === tabId) + currentTabId = null + } + + if (current !== undefined && current !== currentTabId && members.has(current)) { + // Set before switching so the resulting `entry:activated` is a no-op. + currentTabId = current + void docks.switchEntry(memberId(current)) + } + } + + function onMemberActivated(tabId: string): void { + if (disposed || tabId === currentTabId) + return + const record = members.get(tabId) + if (!record) + return + currentTabId = tabId + if (ready) + post({ type: 'navigate', tabId, navTarget: record.tab.navTarget }) + else + hardNav(record.tab) + } + + function onNavigated(tabId: string | undefined): void { + if (disposed || !tabId || !members.has(tabId) || tabId === currentTabId) + return + // Set before switching so `onMemberActivated` treats it as an echo no-op. + currentTabId = tabId + void docks.switchEntry(memberId(tabId)) + } + + function hardNav(tab: FrameTab): void { + iframe.src = tab.fallbackUrl ?? deriveFallbackUrl(anchor.url, tab.navTarget) + } + + // Boot: listen, greet the shim, and arm the no-shim timeout. + listenTarget.addEventListener('message', onMessage) + post({ type: 'hello' }) + handshakeTimer = setTimeout(() => { + handshakeTimer = undefined + }, timeoutMs) + + return { + get ready() { + return ready + }, + get currentTabId() { + return currentTabId + }, + dispose() { + if (disposed) + return + disposed = true + if (handshakeTimer !== undefined) + clearTimeout(handshakeTimer) + listenTarget.removeEventListener('message', onMessage) + for (const record of members.values()) { + record.off?.() + record.handle.dispose() + } + members.clear() + }, + } +} + +/** Resolve the origin to lock `postMessage` to; `'*'` when unresolvable. */ +function resolveOrigin(url: string): string { + const base = (globalThis as { location?: { href?: string } }).location?.href + try { + return new URL(url).origin + } + catch {} + if (base) { + try { + return new URL(url, base).origin + } + catch {} + } + return '*' +} + +/** + * Derive a deep-link/fallback URL from the anchor base + a nav target, using + * hash routing (the safest generic for SPA routers). `state` cannot ride a URL + * and is intentionally dropped here. + */ +function deriveFallbackUrl(base: string, navTarget: NavTarget): string { + const href = (globalThis as { location?: { href?: string } }).location?.href + let url: URL + try { + url = new URL(base, href ?? 'http://localhost') + } + catch { + return base + } + const path = navTarget.path.startsWith('#') ? navTarget.path.slice(1) : navTarget.path + let hash = `#${path}` + const query = navTarget.query + if (query) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + // `typeof` (not `Array.isArray`) so the string branch narrows cleanly — + // `Array.isArray` leaves a `readonly string[]` in the negative branch. + if (typeof value === 'string') { + params.set(key, value) + } + else { + for (const v of value) params.append(key, v) + } + } + const qs = params.toString() + if (qs) + hash += (path.includes('?') ? '&' : '?') + qs + } + url.hash = hash + return url.toString() +} diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 7abd3760..ec21f80d 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -10,6 +10,7 @@ import type { ClientScriptEntry, DevframeDockEntriesGrouped, DevframeDockEntry, + DevframeViewIframe, } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' import type { DockClientScriptContext } from './client-script' @@ -27,6 +28,7 @@ import { connectDevframe } from 'devframe/client' import { createEventEmitter } from 'devframe/utils/events' import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS } from '../constants' import { getDevframeClientContext, setDevframeClientContext } from './context' +import { attachFrameNavClient } from './frame-nav' import { createMessagesClient } from './messages' const DOCKS_STATE_KEY = 'devframe:docks' @@ -102,6 +104,9 @@ export async function createDevframeClientHost( // into the `devframe:docks` shared state (client-only), and are merged with // the server entries — a client dock overriding a server one of the same id. const clientDocks = new Map() + // Live frame-nav adapters for shared-frame anchors, keyed by frameId, so we + // attach one adapter per shared iframe and tear them all down on dispose. + const frameNavAdapters = new Map void>() const loadScriptsEnabled = options.loadClientScripts ?? true const panel = createPanelContext(clientType) @@ -186,6 +191,8 @@ export async function createDevframeClientHost( context, dispose() { for (const off of disposers.splice(0)) off() + for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter() + frameNavAdapters.clear() if (mountedRenderers) { for (const disposeMount of [...mountedRenderers]) disposeMount() } @@ -207,6 +214,27 @@ export async function createDevframeClientHost( } } + // Auto-attach the frame-nav adapter to a shared-frame anchor iframe once its + // iframe element mounts, so a `subTabs` plugin's tabs surface as client-only + // member docks with soft navigation. One adapter per `frameId`. + function maybeAttachFrameNav(meta: DevframeDockEntry, state: DockEntryState): void { + if (meta.type !== 'iframe') + return + const anchor = meta as DevframeViewIframe + if (!anchor.subTabs) + return + const frameId = anchor.frameId ?? anchor.id + const start = (iframe: HTMLIFrameElement): void => { + if (frameNavAdapters.has(frameId)) + return + const adapter = attachFrameNavClient({ frameId, anchor, iframe, docks }) + frameNavAdapters.set(frameId, adapter.dispose) + } + if (state.domElements.iframe) + start(state.domElements.iframe) + state.events.on('dom:iframe:mounted', start) + } + // The merged dock list: server entries from shared state overlaid with any // client-registered docks (client wins on id collision, new ids appended). function currentEntries(): DevframeDockEntry[] { @@ -243,7 +271,9 @@ export async function createDevframeClientHost( seen.add(meta.id) const existing = entryToStateMap.get(meta.id) if (!existing) { - entryToStateMap.set(meta.id, createDockEntryState(meta)) + const state = createDockEntryState(meta) + entryToStateMap.set(meta.id, state) + maybeAttachFrameNav(meta, state) } else if (existing.entryMeta !== meta) { existing.entryMeta = meta @@ -251,8 +281,17 @@ export async function createDevframeClientHost( } } for (const id of [...entryToStateMap.keys()]) { - if (!seen.has(id)) - entryToStateMap.delete(id) + if (seen.has(id)) + continue + const removed = entryToStateMap.get(id) + entryToStateMap.delete(id) + // Tear down a shared-frame adapter when its anchor iframe goes away. + const removedMeta = removed?.entryMeta as DevframeViewIframe | undefined + if (removedMeta?.type === 'iframe' && removedMeta.subTabs) { + const frameId = removedMeta.frameId ?? id + frameNavAdapters.get(frameId)?.() + frameNavAdapters.delete(frameId) + } } docks.entries = entries diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 020912a0..d547d05f 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -4,6 +4,7 @@ export { DEFAULT_CATEGORIES_ORDER } from '../constants' export * from './client-script' export * from './context' export * from './docks' +export * from './frame-nav' export * from './host' export * from './messages' export * from './remote' diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index c898e871..5b02a4f5 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -153,12 +153,37 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { * The id of the iframe, if multiple tabs is assigned with the same id, the iframe will be shared. * * When not provided, it would be treated as a unique frame. + * + * `frameId` is an axis independent of {@link DevframeDockEntryBase.groupId}: + * it decides *which* iframe element a dock renders into (and which soft-nav + * pool it joins), while `groupId` only affects dock-bar grouping. Docks that + * share a `frameId` may live in one group, several groups, or none. */ frameId?: string /** * Optional client script to import into the iframe */ clientScript?: ClientScriptEntry + /** + * Soft-navigation target within a shared frame. Set on a **member** dock + * (one of several docks sharing a {@link frameId}) to describe which internal + * view the embedded app should show. The hub treats {@link NavTarget.path} as + * opaque and hands it to the frame's nav shim over `postMessage`; switching to + * this dock performs client-side navigation instead of reloading the iframe. + * + * The anchor dock (the one flagged with {@link subTabs}) leaves this unset. + */ + navTarget?: NavTarget + /** + * Marks this iframe as a **shared-frame anchor** whose sub-tabs are discovered + * at runtime over a host↔iframe `postMessage` protocol. The client host + * auto-attaches the frame-nav adapter when this iframe mounts: it runs the + * ready handshake, materializes one client-only member dock per reported tab + * (grouped/soft-navigated via this anchor's {@link frameId}), and drives the + * live navigation loop. Absent a shim, the anchor simply renders as a single + * plain iframe dock. + */ + subTabs?: FrameSubTabsConfig /** * Enable remote-UI mode: the hub injects a connection descriptor * (WS URL + pre-approved auth token) into the iframe URL so a hosted @@ -173,6 +198,40 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { remote?: boolean | RemoteDockOptions } +/** + * A structured, soft-navigation target within a shared frame. `path` is opaque + * to the hub — the embedded app maps it onto its own router. + * + * Kept to `path` + `query` so the shape survives shared-state's `Immutable` + * projection cleanly (a `DevframeViewIframe` must still narrow back from its + * immutable form). An `unknown`/recursive history-`state` field breaks that + * round-trip, so richer per-navigation state is intentionally out of scope for + * now — carry it in `query` or the app's own store. + */ +export interface NavTarget { + path: string + // `readonly` arrays keep this shape stable under the shared-state `Immutable` + // projection, so a `Immutable` still narrows back to + // `DevframeViewIframe` (a mutable `string[]` would not). + query?: Record +} + +/** + * Configuration for a {@link DevframeViewIframe.subTabs shared-frame anchor}. + */ +export interface FrameSubTabsConfig { + /** Transport for tab discovery + the live nav loop. */ + protocol: 'postmessage' + /** + * How long (ms) the adapter waits for the shim's `ready` before treating the + * frame as having no shim (the anchor renders as a single plain iframe dock, + * and a navigation requested before readiness hard-navigates). + * + * @default 3000 + */ + handshakeTimeoutMs?: number +} + export interface RemoteDockOptions { /** * How to pass the connection descriptor to the hosted page. diff --git a/plans/shared-iframe-soft-nav.md b/plans/shared-iframe-soft-nav.md new file mode 100644 index 00000000..a4c266d8 --- /dev/null +++ b/plans/shared-iframe-soft-nav.md @@ -0,0 +1,448 @@ +# Shared-iframe docks with soft navigation + +**Driving use case:** hosting a foreign multi-tab devtool (e.g. **Nuxt DevTools**) as a set of first-class hub docks that all share **one** live iframe and switch between views via **client-side (soft) navigation**, for a unified experience. + +**Status — the hub side is shipped and documented.** This doc is the design record, plus the hand-off spec for the parts that live in other repos: the viewer UI contract (§6) and the embedded-app nav shim (§10). + +Shipped in `@devframes/hub`: + +- **Data model** — `frameId` + `navTarget` + `subTabs` on `DevframeViewIframe` ([`packages/hub/src/types/docks.ts`](../packages/hub/src/types/docks.ts)). +- **Client-only docks** — `docks.register()` / `update()` on the client context ([#129](https://github.com/devframes/devframe/pull/129)). +- **Frame-nav adapter** — `attachFrameNavClient`, auto-attached by `createDevframeClientHost` ([`packages/hub/src/client/frame-nav.ts`](../packages/hub/src/client/frame-nav.ts)). +- **Docs** — [Shared-iframe soft navigation](../docs/guide/client-context.md#shared-iframe-soft-navigation). +- **Examples** — the "Tabbed Tool" in both `examples/minimal-{vite,next}-devframe-hub`. +- **Tests** — [`packages/hub/src/client/__tests__/frame-nav.test.ts`](../packages/hub/src/client/__tests__/frame-nav.test.ts). + +Remaining work lives downstream (§11): the Vite DevTools UI contract and the Nuxt DevTools shim. + +--- + +## 1. Summary + +A large integration like Nuxt DevTools has many internal tabs (Modules, Timeline, Plugins, …) inside a single SPA with its own router. Today devframe mounts an integration as exactly **one** iframe dock ([`packages/hub/src/node/mount-devframe.ts:89`](../packages/hub/src/node/mount-devframe.ts)); the integration's own tabs are invisible to the hub. We want each of those tabs to appear as its **own hub dock**, participating in the dock bar, grouping, ordering, pinning, badges, and the command palette — while **reusing one already-booted iframe** and switching views **without reloading**. + +The design splits cleanly into two concerns, mirroring the existing a11y precedent (RPC carries the durable data model; a separate direct channel carries the live loop — see [`plugins/a11y/src/shared/protocol.ts:7`](../plugins/a11y/src/shared/protocol.ts)): + +- **Data model (hub-owned):** a shared-iframe axis (`frameId`) on iframe docks, plus the ability for the **viewer client** to register **client-only docks** (docks that live only in this client's dock context, never projected to the server's `devframe:docks` shared state). +- **Live loop (host↔iframe `postMessage`):** a small, versioned, origin-locked protocol carrying a **tab manifest**, **navigate** commands, **navigated** reports, and a **ready** handshake. The embedded app implements only this shim — it need **not** be a devframe and takes **no** hub/RPC dependency. + +The embedded app stays maximally decoupled: **~30 lines of `postMessage` shim** and it slots into the hub as a group of managed docks with instant soft-nav between them, working cross-origin and even in static builds (no server dependency for the loop). + +--- + +## 2. Terminology + +| Term | Meaning | +|---|---| +| **Shared frame** | One `