diff --git a/examples/minimal-next-devframe-hub/spa/next-demo-tool-b/index.html b/examples/minimal-next-devframe-hub/spa/next-demo-tool-b/index.html
new file mode 100644
index 0000000..488190a
--- /dev/null
+++ b/examples/minimal-next-devframe-hub/spa/next-demo-tool-b/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Next Demo Tool B
+
+
+
+ Next Demo Tool B
+ Served from
+ A second demo devframe, mounted alongside the first to demonstrate dock switching.
+
+
+
diff --git a/examples/minimal-next-devframe-hub/spa/next-demo-tool/index.html b/examples/minimal-next-devframe-hub/spa/next-demo-tool/index.html
new file mode 100644
index 0000000..8b6c138
--- /dev/null
+++ b/examples/minimal-next-devframe-hub/spa/next-demo-tool/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Next Demo Tool
+
+
+
+ Next Demo Tool
+ Served from
+ This SPA is mounted by minimal-next-devframe-hub via DevframeHost.mountStatic().
+
+
+
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
new file mode 100644
index 0000000..ea1d1b6
--- /dev/null
+++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts
@@ -0,0 +1,103 @@
+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'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const CONTENT_TYPES: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.htm': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.mjs': 'application/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.ico': 'image/x-icon',
+}
+
+interface ResolvedFile {
+ abs: string
+ size: number
+ mtime: Date
+}
+
+async function statFile(abs: string): Promise {
+ try {
+ const s = await stat(abs)
+ if (!s.isFile())
+ return null
+ return { abs, size: s.size, mtime: s.mtime }
+ }
+ catch {
+ return null
+ }
+}
+
+async function resolveTarget(absDir: string, urlPath: string): Promise {
+ let cleaned = decodeURIComponent(urlPath || '/').replace(/[?#].*$/, '')
+ if (cleaned.endsWith('/'))
+ cleaned = cleaned.slice(0, -1)
+ if (cleaned.startsWith('/'))
+ cleaned = cleaned.slice(1)
+
+ const abs = normalize(join(absDir, cleaned))
+ if (abs !== absDir && !abs.startsWith(absDir + sep))
+ return null
+
+ const direct = await statFile(abs)
+ if (direct)
+ return direct
+
+ // Directory → index.html
+ try {
+ const s = await stat(abs)
+ if (s.isDirectory()) {
+ const candidate = await statFile(join(abs, 'index.html'))
+ if (candidate)
+ return candidate
+ }
+ }
+ catch {
+ // not found / not a directory — continue
+ }
+
+ // SPA fallback for extensionless paths
+ if (!/\.[a-z0-9]+$/i.test(cleaned)) {
+ const fallback = await statFile(join(absDir, 'index.html'))
+ if (fallback)
+ return fallback
+ }
+
+ return null
+}
+
+export async function GET(request: Request): Promise {
+ await ensureMinimalNextDevframeHub()
+
+ const pathname = new URL(request.url).pathname
+ const hit = getStaticMount(pathname)
+ if (!hit)
+ return new Response(null, { status: 404 })
+
+ const file = await resolveTarget(resolve(hit.distDir), hit.relative)
+ if (!file)
+ return new Response(null, { status: 404 })
+
+ return new Response(Readable.toWeb(createReadStream(file.abs)) as ReadableStream, {
+ status: 200,
+ headers: {
+ 'Content-Type': CONTENT_TYPES[extname(file.abs).toLowerCase()] ?? 'application/octet-stream',
+ 'Content-Length': String(file.size),
+ 'Last-Modified': file.mtime.toUTCString(),
+ 'Cache-Control': 'no-store',
+ },
+ })
+}
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 7bc5dff..41a0966 100644
--- a/examples/minimal-next-devframe-hub/src/client/app/globals.css
+++ b/examples/minimal-next-devframe-hub/src/client/app/globals.css
@@ -6,48 +6,99 @@
body {
margin: 0;
- padding: 1.5rem 2rem;
+ height: 100vh;
+ overflow: hidden;
}
-main {
- max-width: 800px;
- margin-inline: auto;
+.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;
}
-header h1 {
- margin-bottom: 0.25rem;
+.app-header {
+ grid-area: header;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
}
-header p {
- margin-top: 0;
+.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;
}
-section {
- margin-block: 1.5rem;
+.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: 1rem;
+ font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
- border-bottom: 1px solid currentcolor;
- padding-bottom: 0.25rem;
+ 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.5rem 0.75rem;
+ padding: 0.4rem 0.6rem;
border: 1px solid color-mix(in srgb, currentcolor 15%, transparent);
- border-radius: 0.5rem;
- margin-bottom: 0.5rem;
+ border-radius: 0.4rem;
font-family: ui-monospace, monospace;
- font-size: 0.9rem;
+ font-size: 0.8rem;
}
li.muted {
@@ -62,29 +113,54 @@ code {
border-radius: 0.25em;
}
-button {
+.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;
- padding: 0.5rem 1rem;
- border-radius: 0.5rem;
- border: 1px solid currentcolor;
+ font-size: 0.85rem;
background: transparent;
+ border: 1px solid transparent;
+ border-radius: 0.4rem;
cursor: pointer;
+ color: inherit;
}
-button:hover {
- background: color-mix(in srgb, currentcolor 10%, transparent);
+.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;
}
-#status {
- font-family: ui-monospace, monospace;
+button {
+ font: inherit;
font-size: 0.85rem;
- opacity: 0.7;
+ 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 {
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 65e3282..7225ea7 100644
--- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx
+++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx
@@ -9,7 +9,7 @@ import type {
} from '@devframes/hub/types'
import type { ReactNode } from 'react'
import { connectDevframe } from '@devframes/hub/client'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
const HUB_BASE = '/__hub/'
@@ -18,8 +18,13 @@ interface Status {
kind?: 'ready' | 'error'
}
+type IframeDock = DevframeDockEntry & { type: 'iframe', url: string }
type TerminalSummary = Pick
+function isIframeDock(d: DevframeDockEntry): d is IframeDock {
+ return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
+}
+
export default function Page() {
const [status, setStatus] = useState({ text: 'Connecting...' })
const [docks, setDocks] = useState([])
@@ -27,6 +32,7 @@ export default function Page() {
const [messages, setMessages] = useState([])
const [terminals, setTerminals] = useState([])
const [pingResult, setPingResult] = useState('Run ping')
+ const [selectedDockId, setSelectedDockId] = useState(null)
const rpcRef = useRef(null)
useEffect(() => {
@@ -99,6 +105,19 @@ export default function Page() {
}
}, [])
+ const iframeDocks = useMemo(() => docks.filter(isIframeDock), [docks])
+
+ useEffect(() => {
+ if (selectedDockId && !iframeDocks.some(d => d.id === selectedDockId)) {
+ setSelectedDockId(null)
+ return
+ }
+ if (!selectedDockId && iframeDocks.length > 0)
+ setSelectedDockId(iframeDocks[0].id)
+ }, [iframeDocks, selectedDockId])
+
+ const selectedDock = iframeDocks.find(d => d.id === selectedDockId) ?? null
+
async function ping() {
if (!rpcRef.current)
return
@@ -115,73 +134,78 @@ export default function Page() {
}
return (
-
-