Skip to content

Commit 5764cf9

Browse files
authored
feat(hub): shared-iframe docks with postMessage soft navigation (#128)
1 parent e170f26 commit 5764cf9

23 files changed

Lines changed: 1934 additions & 85 deletions

File tree

docs/guide/client-context.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,37 @@ The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client scri
153153
## Iframe panels
154154

155155
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.
156+
157+
## Shared-iframe soft navigation
158+
159+
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`.
160+
161+
```ts
162+
await mountDevframe(ctx, nuxtDevtools, {
163+
dock: { frameId: 'nuxt-devtools', subTabs: { protocol: 'postmessage' } },
164+
})
165+
```
166+
167+
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:
168+
169+
| Message | Direction | Meaning |
170+
|---|---|---|
171+
| `ready` / `manifest` | frame → host | the current tab list (`{ tabs, current }`), on load and whenever it changes |
172+
| `navigate` | host → frame | show this view (`{ tabId, navTarget }`) — the app routes client-side |
173+
| `navigated` | frame → host | the app navigated internally, so the host highlights the matching dock |
174+
175+
The adapter materializes one [client-only dock](#client-only-docks) per reported tab (id `<frameId>:<tabId>`), 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.
176+
177+
`frameId` is independent of [`groupId`](./hub#grouping-dock-entries): members sharing one iframe may live in a group, several groups, or none.
178+
179+
### The viewer's part
180+
181+
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:
182+
183+
```ts
184+
const state = ctx.docks.getStateById(anchorId)!
185+
state.domElements.iframe = iframeEl
186+
state.events.emit('dom:iframe:mounted', iframeEl)
187+
```
188+
189+
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.

docs/guide/hub.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ ctx.docks.register({
199199

200200
`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.
201201

202+
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).
203+
202204
### The dual role of `category`
203205

204206
`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
261263
- [`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.
262264
- [`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.
263265

266+
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.
267+
264268
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.
265269

266270
## Diagnostics
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Next Tabbed Tool</title>
7+
<style>
8+
:root {
9+
color-scheme: light dark;
10+
font-family: system-ui, sans-serif;
11+
--fg: #1c2024;
12+
--muted: #6b7280;
13+
--line: #e4e6ea;
14+
--accent: #3a6a45;
15+
--panel: #f6f7f8;
16+
}
17+
@media (prefers-color-scheme: dark) {
18+
:root { --fg: #e6e8ea; --muted: #9aa2ad; --line: #2a2d31; --accent: #7fb98a; --panel: #17191c; }
19+
body { background: #0f1113; }
20+
}
21+
* { box-sizing: border-box; }
22+
body { margin: 0; color: var(--fg); }
23+
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
24+
header .brand { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px; }
25+
header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
26+
nav { display: flex; gap: 4px; margin-left: 8px; }
27+
nav button {
28+
appearance: none; border: 1px solid transparent; background: transparent; color: var(--muted);
29+
font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 7px; cursor: pointer;
30+
}
31+
nav button:hover { color: var(--fg); background: var(--panel); }
32+
nav button[aria-current="true"] { color: var(--fg); background: var(--panel); border-color: var(--line); }
33+
main { padding: 24px; }
34+
h1 { margin: 0 0 6px; font-size: 18px; }
35+
p { max-width: 60ch; line-height: 1.6; color: var(--muted); }
36+
code { font-family: ui-monospace, monospace; background: rgba(127, 127, 127, 0.16); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
37+
.view { display: none; }
38+
.view[data-active="true"] { display: block; }
39+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; margin-top: 16px; }
40+
.card { border: 1px solid var(--line); border-radius: 10px; padding: 14px; background: var(--panel); }
41+
.card b { display: block; font-size: 13px; }
42+
.card span { font-size: 12px; color: var(--muted); }
43+
ol { padding-left: 18px; }
44+
ol li { margin: 6px 0; }
45+
.tag { display: inline-block; font-size: 11px; font-family: ui-monospace, monospace; color: var(--accent); }
46+
</style>
47+
</head>
48+
<body>
49+
<header>
50+
<span class="brand"><span class="dot"></span>Next Tabbed Tool</span>
51+
<nav id="tabs"></nav>
52+
</header>
53+
<main>
54+
<section class="view" data-view="overview">
55+
<h1>Overview</h1>
56+
<p>
57+
One iframe, many hub docks. This SPA hosts its own internal views
58+
(<span class="tag">Overview · Components · Timeline · Settings</span>)
59+
and exposes each as a first-class hub dock that shares
60+
<em>this single iframe</em>. Switching docks soft-navigates here — no
61+
reload — via the <code>devframe:frame-nav</code> postMessage protocol.
62+
</p>
63+
<div class="grid">
64+
<div class="card"><b>Shared frame</b><span>frameId · next-tabbed-tool</span></div>
65+
<div class="card"><b>Transport</b><span>window.postMessage</span></div>
66+
<div class="card"><b>Docks</b><span>client-only, from manifest</span></div>
67+
<div class="card"><b>Nav</b><span>bidirectional</span></div>
68+
</div>
69+
</section>
70+
71+
<section class="view" data-view="components">
72+
<h1>Components</h1>
73+
<p>A stand-in view. Selecting the <b>Components</b> dock in the hub posts
74+
<code>navigate</code> to this frame, which routes here client-side.</p>
75+
<div class="grid">
76+
<div class="card"><b>&lt;Button /&gt;</b><span>12 usages</span></div>
77+
<div class="card"><b>&lt;Dialog /&gt;</b><span>4 usages</span></div>
78+
<div class="card"><b>&lt;Tabs /&gt;</b><span>7 usages</span></div>
79+
</div>
80+
</section>
81+
82+
<section class="view" data-view="timeline">
83+
<h1>Timeline</h1>
84+
<p>Navigate <em>inside</em> this frame (the tab bar above) and watch the
85+
hub's active dock follow — the frame posts <code>navigated</code> back.</p>
86+
<ol>
87+
<li>10:02 — build started</li>
88+
<li>10:02 — 214 modules transformed</li>
89+
<li>10:03 — page reload</li>
90+
</ol>
91+
</section>
92+
93+
<section class="view" data-view="settings">
94+
<h1>Settings</h1>
95+
<p>The manifest each tab reports (<code>title</code>, <code>icon</code>,
96+
<code>navTarget</code>) becomes a client-only dock in the hub — never
97+
written to the <code>devframe:docks</code> shared state.</p>
98+
<div class="grid">
99+
<div class="card"><b>Theme</b><span>follows OS</span></div>
100+
<div class="card"><b>Telemetry</b><span>off</span></div>
101+
</div>
102+
</section>
103+
</main>
104+
105+
<script>
106+
// ── The tabbed tool's own tiny router ────────────────────────────────
107+
const TABS = [
108+
{ id: 'overview', title: 'Overview', icon: 'ph:house-duotone', navTarget: { path: '/overview' } },
109+
{ id: 'components', title: 'Components', icon: 'ph:puzzle-piece-duotone', navTarget: { path: '/components' } },
110+
{ id: 'timeline', title: 'Timeline', icon: 'ph:clock-duotone', navTarget: { path: '/timeline' } },
111+
{ id: 'settings', title: 'Settings', icon: 'ph:gear-duotone', navTarget: { path: '/settings' } },
112+
]
113+
const pathToTab = path => (path || '/overview').replace(/^#?\/?/, '') || 'overview'
114+
let current = pathToTab(location.hash.slice(1))
115+
if (!TABS.some(t => t.id === current)) current = 'overview'
116+
117+
const navEl = document.getElementById('tabs')
118+
navEl.innerHTML = TABS.map(t => `<button data-tab="${t.id}">${t.title}</button>`).join('')
119+
120+
function render() {
121+
for (const el of document.querySelectorAll('.view'))
122+
el.dataset.active = String(el.dataset.view === current)
123+
for (const b of navEl.querySelectorAll('button'))
124+
b.setAttribute('aria-current', String(b.dataset.tab === current))
125+
}
126+
127+
// ── The postMessage nav shim (devframe:frame-nav protocol) ───────────
128+
const CHANNEL = 'devframe:frame-nav'
129+
const VERSION = 1
130+
const FRAME_ID = 'next-tabbed-tool'
131+
// Best-effort parent origin so the first (unsolicited) `ready` can be sent
132+
// even if the host's `hello` is missed during iframe boot.
133+
let hostOrigin
134+
= (location.ancestorOrigins && location.ancestorOrigins[0])
135+
|| (document.referrer ? new URL(document.referrer).origin : '*')
136+
137+
function post(message) {
138+
parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, hostOrigin)
139+
}
140+
const manifest = () => TABS.map(t => ({ id: t.id, title: t.title, icon: t.icon, navTarget: t.navTarget }))
141+
142+
function setView(id, fromHost) {
143+
if (!TABS.some(t => t.id === id)) return
144+
current = id
145+
location.hash = `/${id}`
146+
render()
147+
// Report internal navigation so the host highlights the matching dock.
148+
// When the host itself asked us to navigate, its idempotent guard makes
149+
// this echo a no-op.
150+
if (!fromHost) post({ type: 'navigated', tabId: id })
151+
}
152+
153+
addEventListener('message', (e) => {
154+
const d = e.data
155+
if (!d || d.channel !== CHANNEL || d.v !== VERSION || d.from !== 'host') return
156+
if (d.frameId && d.frameId !== FRAME_ID) return
157+
hostOrigin = e.origin || hostOrigin
158+
if (d.type === 'hello')
159+
post({ type: 'ready', tabs: manifest(), current })
160+
else if (d.type === 'navigate')
161+
setView(pathToTab(d.navTarget && d.navTarget.path), true)
162+
})
163+
164+
// The user clicking a tab inside this frame drives the loop the other way.
165+
navEl.addEventListener('click', (e) => {
166+
const b = e.target.closest('button[data-tab]')
167+
if (b) setView(b.dataset.tab, false)
168+
})
169+
170+
render()
171+
// Announce ourselves on load (the host also sends `hello` — either wins).
172+
post({ type: 'ready', tabs: manifest(), current })
173+
</script>
174+
</body>
175+
</html>

examples/minimal-next-devframe-hub/src/client/app/icons.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ const ICON_CLASS: Record<string, string> = {
1313
'ph:rocket-duotone': 'i-ph-rocket-duotone',
1414
'ph:wrench-duotone': 'i-ph-wrench-duotone',
1515
'ph:plug-duotone': 'i-ph-plug-duotone',
16+
'ph:layout-duotone': 'i-ph-layout-duotone',
17+
'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
18+
'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
19+
'ph:house-duotone': 'i-ph-house-duotone',
20+
'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
21+
'ph:clock-duotone': 'i-ph-clock-duotone',
22+
'ph:gear-duotone': 'i-ph-gear-duotone',
23+
'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
1624
}
1725

1826
/**

0 commit comments

Comments
 (0)