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 ( -
-
+
+

Minimal Next Devframe Hub

-

- Protocol witness: verifies - {' '} - @devframes/hub - {' '} - end to end from a Next.js host. +

+ {status.text}

-
- {status.text} -
- - - {docks.map(dock => ( -
  • - {dock.title} - {' '} - {dock.id} - {'badge' in dock && dock.badge - ? {`[${dock.badge}]`} - : null} -
  • - ))} -
    - - - {commands.map(command => ( -
  • - {command.title} - {' '} - {command.id} -
  • - ))} -
    - -
    - -
    - - - {messages.map(message => ( -
  • - {`[${message.level}]`} - {' '} - {message.message} -
  • - ))} -
    - - - {terminals.map(terminal => ( -
  • - {terminal.title} - {' '} - {terminal.id} - {' '} - {terminal.status} -
  • - ))} -
    -
    + + +
    + +
    -
    -

    Messages

    -
    • No messages yet.
    -
    +
    +
    +

    Commands

    +
    • Waiting for snapshot…
    +
    + +
    +
    -
    -

    Terminals

    -
    • No terminal sessions.
    -
    - +
    +

    Messages

    +
    • No messages yet.
    +
    + +
    +

    Terminals

    +
    • No terminal sessions.
    +
    +
    + diff --git a/examples/minimal-vite-devframe-hub/spa/demo-tool-b/index.html b/examples/minimal-vite-devframe-hub/spa/demo-tool-b/index.html new file mode 100644 index 0000000..6221440 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/spa/demo-tool-b/index.html @@ -0,0 +1,22 @@ + + + + + + Demo Tool B + + + +

    Demo Tool B

    +

    Served from

    +

    A second demo devframe, mounted alongside the first to demonstrate dock switching.

    + + + diff --git a/examples/minimal-vite-devframe-hub/spa/demo-tool/index.html b/examples/minimal-vite-devframe-hub/spa/demo-tool/index.html new file mode 100644 index 0000000..0199a51 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/spa/demo-tool/index.html @@ -0,0 +1,22 @@ + + + + + + Demo Tool + + + +

    Demo Tool

    +

    Served from

    +

    This SPA is mounted by minimal-vite-devframe-hub via DevframeHost.mountStatic().

    + + + diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index c98db05..babe6c4 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -15,6 +15,9 @@ const commandsEl = document.querySelector('#commands')! const messagesEl = document.querySelector('#messages')! const terminalsEl = document.querySelector('#terminals')! const pingBtn = document.querySelector('#ping')! +const iframeEl = document.querySelector('#dock-iframe')! + +let selectedDockId: string | null = null function setStatus(text: string, klass?: 'ready' | 'error') { connEl.textContent = text @@ -29,6 +32,10 @@ function renderList(host: HTMLElement, items: T[], render: (item: T) => strin host.innerHTML = items.map(render).join('') } +function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'iframe', url: string } { + return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' +} + async function main() { setStatus('Connecting…') @@ -40,10 +47,40 @@ async function main() { 'devframe:docks', { initialValue: [] }, ) - const renderDocks = () => renderList(docksEl, docks.value() ?? [], (d) => { - const badge = d.badge ? ` [${d.badge}]` : '' - return `
  • ${d.title} ${d.id}${badge}
  • ` + + const renderDocks = () => { + const iframeDocks = (docks.value() ?? []).filter(isIframeDock) + + if (selectedDockId && !iframeDocks.some(d => d.id === selectedDockId)) + selectedDockId = null + if (!selectedDockId && iframeDocks.length > 0) + selectedDockId = iframeDocks[0].id + + if (!iframeDocks.length) { + docksEl.innerHTML = '
  • No iframe docks
  • ' + iframeEl.src = 'about:blank' + return + } + + renderList(docksEl, iframeDocks, d => + `
  • `) + + const selected = iframeDocks.find(d => d.id === selectedDockId) + if (selected && iframeEl.getAttribute('src') !== selected.url) + iframeEl.src = selected.url + } + + docksEl.addEventListener('click', (event) => { + const target = (event.target as HTMLElement).closest('button[data-dock-id]') + if (!target) + return + const id = target.dataset.dockId + if (!id || id === selectedDockId) + return + selectedDockId = id + renderDocks() }) + docks.on('updated', renderDocks) renderDocks() diff --git a/examples/minimal-vite-devframe-hub/src/client/style.css b/examples/minimal-vite-devframe-hub/src/client/style.css index 6127b5e..a48e774 100644 --- a/examples/minimal-vite-devframe-hub/src/client/style.css +++ b/examples/minimal-vite-devframe-hub/src/client/style.css @@ -6,45 +6,99 @@ body { margin: 0; - padding: 1.5rem 2rem; - max-width: 800px; - margin-inline: auto; + height: 100vh; + overflow: hidden; } -header h1 { - margin-bottom: 0.25rem; +.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 p { - margin-top: 0; +.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; } -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 { @@ -59,25 +113,53 @@ code { 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 { + margin-top: 0.5rem; +} + button { font: inherit; - padding: 0.5rem 1rem; - border-radius: 0.5rem; + 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 { - font-family: ui-monospace, monospace; - font-size: 0.85rem; - opacity: 0.7; -} - #status.error #conn { color: #c33; } diff --git a/examples/minimal-vite-devframe-hub/src/devframe-b.ts b/examples/minimal-vite-devframe-hub/src/devframe-b.ts new file mode 100644 index 0000000..bcc046d --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/devframe-b.ts @@ -0,0 +1,30 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' + +export default defineDevframe({ + id: 'demo-tool-b', + name: 'Demo Tool B', + icon: 'ph:wrench-duotone', + basePath: '/__demo-tool-b/', + cli: { + distDir: fileURLToPath(new URL('../spa/demo-tool-b/', import.meta.url)), + }, + async setup(rawCtx) { + const ctx = rawCtx as unknown as DevframeHubContext + + ctx.commands.register({ + id: 'demo-tool-b:say-hello', + title: 'Demo B · Say Hello', + icon: 'ph:hand-waving-duotone', + category: 'demo', + handler: () => 'Hello from demo-tool-b!', + }) + + await ctx.messages.add({ + level: 'info', + message: 'Second demo devframe loaded', + description: 'A second mountDevframe() call — proves the switcher has more than one option.', + }) + }, +}) diff --git a/examples/minimal-vite-devframe-hub/src/devframe.ts b/examples/minimal-vite-devframe-hub/src/devframe.ts index b8062b3..ed54bd6 100644 --- a/examples/minimal-vite-devframe-hub/src/devframe.ts +++ b/examples/minimal-vite-devframe-hub/src/devframe.ts @@ -1,4 +1,5 @@ import type { DevframeHubContext } from '@devframes/hub/node' +import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' /** @@ -15,6 +16,9 @@ export default defineDevframe({ name: 'Demo Tool', icon: 'ph:rocket-duotone', basePath: '/__demo-tool/', + cli: { + distDir: fileURLToPath(new URL('../spa/demo-tool/', import.meta.url)), + }, async setup(rawCtx) { const ctx = rawCtx as unknown as DevframeHubContext diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts index 46a79d9..52295f5 100644 --- a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -6,6 +6,7 @@ import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { startHttpAndWs } from 'devframe/node' +import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { join } from 'pathe' @@ -78,10 +79,8 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = const cwd = viteConfig!.root const host: DevframeHost = { - mountStatic() { - // Static mounting for devframe SPAs would route through Vite's - // middleware in a fuller kit. This minimal example doesn't - // host any per-devframe SPA, so the no-op is honest. + mountStatic(base, distDir) { + server.middlewares.use(base, serveStaticNodeMiddleware(distDir)) }, resolveOrigin() { const resolved = server.resolvedUrls?.local?.[0] diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index 925e42b..e1b17cc 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,13 +1,14 @@ import { defineConfig } from 'vite' import { alias } from '../../alias' import demoDevframe from './src/devframe' +import demoDevframeB from './src/devframe-b' import { minimalViteDevframeHub } from './src/minimal-vite-devframe-hub' export default defineConfig({ resolve: { alias }, plugins: [ minimalViteDevframeHub({ - devframes: [demoDevframe], + devframes: [demoDevframe, demoDevframeB], }), ], })