Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/errors/DF8107.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
outline: deep
---

# DF8107: Unknown Dock Activation Target

## Message

> Dock activation requested for unknown dock id "`{id}`"

## Cause

`ctx.docks.activate(dockId)` — reached via the `hub:docks:activate` RPC, which lets any connected client (e.g. a mounted devframe in its own iframe) steer the host shell's active dock — was called with a `dockId` that no registered dock entry owns.

This is a warning, not a thrown error: the activation is still broadcast so a dock that registers momentarily later can pick it up, and both the client host and each dock ignore ids they don't recognize. A mis-addressed activation is inert rather than fatal — the warning exists so a typo doesn't fail silently.

## Fix

Pass a `dockId` that matches a registered dock entry. Ids are case-sensitive, so check for typos, and make sure the target dock is registered before activating it. For the terminals dock the id is `devframes-plugin-terminals`:

```ts
await rpc.call('hub:docks:activate', {
dockId: 'devframes-plugin-terminals',
params: { sessionId },
})
```

## Source

- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts) — `DevframeDocksHost.activate()` reports this when the requested dock id isn't in `views`.
27 changes: 24 additions & 3 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi

| Subsystem | Surface | Purpose |
|---|---|---|
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. |
| `ctx.docks` | `register / update / values / activate` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. `activate(dockId, params?)` steers which dock the viewer shows — see [Cross-iframe dock activation](#cross-iframe-dock-activation). |
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. |
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
Expand All @@ -21,12 +21,31 @@ Plus a `createJsonRenderer(spec)` factory for building remote-UI panels via the

## Built-in RPC

Every hub context auto-registers this RPC function so framework kits don't reimplement it:
Every hub context auto-registers these RPC functions so framework kits don't reimplement them:

- `hub:commands:execute` — invoke a registered server command by id. `await rpc.call('hub:commands:execute', 'my-tool:do-thing', ...args)`.
- `hub:docks:activate` — switch the viewer's active dock. `await rpc.call('hub:docks:activate', { dockId, params })` — see [Cross-iframe dock activation](#cross-iframe-dock-activation).

Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface.

## Cross-iframe dock activation

The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell.

```ts
// From inside a mounted devframe's iframe (its own RPC client):
await rpc.call('hub:docks:activate', {
dockId: 'devframes-plugin-terminals',
params: { sessionId }, // opaque bag the target dock interprets
})
```

The hub broadcasts the request live over `devframe:docks:activate` (the client host calls its local `switchEntry(dockId)`) and mirrors it into the `devframe:docks:active` shared-state slot, so a dock that mounts *because* of the switch still converges on the request instead of missing the broadcast. `params` is an opaque, serializable bag the target dock reads — the [terminals dock](/plugins/terminals#focusing-a-session) reads `params.sessionId` to focus a specific session. Unknown dock ids degrade to a no-op (warned server-side as [DF8107](/errors/DF8107)); the target dock ignores `params` it doesn't recognize.

This is what lets Vite DevTools' Rolldown analyzer spawn a `vite build` via `ctx.terminals.startChildProcess` and then navigate the user straight to that build's terminal session.

Server-side, the same switch is available as `ctx.docks.activate(dockId, params?)`.

## Mounting a devframe into a hub

`mountDevframe(ctx, def)` is the framework-neutral primitive that registers any `DevframeDefinition` as a dock and runs its `setup(ctx)`:
Expand Down Expand Up @@ -136,9 +155,11 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys
| `devframe:docks` shared state | `DevframeDockEntry[]` | Every dock entry the mounted integrations registered. |
| `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). |
| `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. |
| `devframe:docks:active` shared state | `DevframeDocksActiveState` | The most recent [dock activation](#cross-iframe-dock-activation) request, so a dock that mounts in response converges on it. |
| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. |
| `hub:docks:activate` RPC | `({ dockId, params? }) => void` | Switch the active dock from any client. |

Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`.
Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you.

## Running plugin code in the host page

Expand Down
14 changes: 14 additions & 0 deletions docs/plugins/terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea

A session from `ctx.terminals.startChildProcess()` carries a `getResult()` accessor shaped like `tinyexec`'s `Result` — `await`able to `{ stdout, stderr, exitCode }` (captured separately from the merged display stream), with live `pid` / `exitCode` / `killed` getters and `kill()` in the meantime. That's the seam for migrating an existing `tinyexec`/`execa`-based "run a subprocess and get its result" API onto the hub's terminals: keep the same calling code, swap the runner for `startChildProcess()`, and the session's output shows up in every hub-aware terminal panel for free.

## Focusing a session

The panel reacts to the hub's [cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation): when an activation targets this dock (`dockId: 'devframes-plugin-terminals'`) and carries a `sessionId`, the panel selects that session. This lets another tool spawn a build and jump the user straight to its output:

```ts
// e.g. right after ctx.terminals.startChildProcess(..., { id: sessionId, ... })
await rpc.call('hub:docks:activate', {
dockId: 'devframes-plugin-terminals',
params: { sessionId },
})
```

It works whether the panel is already open (it reacts to the `devframe:docks:active` shared-state slot) or mounts in response to the switch (it reads the slot on start and converges). Focus is one-shot: an unknown or not-yet-arrived session id waits for that session to appear, and the user's own tab clicks are always honored afterward. A session id that never appears is a no-op — the default selection (most-recent session) stands.

## Source

[`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals)
26 changes: 25 additions & 1 deletion packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function createStubSharedState<T>(initial: T): StubSharedState<T> {
function createStubRpc() {
const calls: any[][] = []
const states = new Map<string, StubSharedState<any>>()
const definitions = new Map<string, { name: string, type: string, handler?: (...args: any[]) => any }>()
const rpc = {
sharedState: {
async get(key: string, options?: { initialValue?: any }) {
Expand All @@ -47,8 +48,14 @@ function createStubRpc() {
return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] }
return `rpc:${args[0]}`
},
client: {
definitions,
register(fn: { name: string, type: string, handler?: (...args: any[]) => any }) {
definitions.set(fn.name, fn)
},
},
} as unknown as DevframeRpcClient
return { rpc, calls, states }
return { rpc, calls, states, definitions }
}

function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
Expand Down Expand Up @@ -115,6 +122,23 @@ describe('createDevframeClientHost', () => {
host.dispose()
})

it('switches the active dock when the hub broadcasts devframe:docks:activate', async () => {
const { rpc, states, definitions } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('devframes-plugin-terminals')])

// Simulate the hub's server→client broadcast.
const handler = definitions.get('devframe:docks:activate')!.handler!
handler({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } })
await vi.waitFor(() => expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals'))

// Unknown dock ids degrade to a no-op (the previous selection stands).
handler({ dockId: 'ghost' })
await new Promise(r => setTimeout(r, 0))
expect(host.context.docks.selectedId).toBe('devframes-plugin-terminals')
host.dispose()
})

it('executes client commands locally and server commands over hub:commands:execute', async () => {
const { rpc, calls } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
Expand Down
27 changes: 27 additions & 0 deletions packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { createMessagesClient } from './messages'
const DOCKS_STATE_KEY = 'devframe:docks'
const COMMANDS_STATE_KEY = 'devframe:commands'
const USER_SETTINGS_STATE_KEY = 'devframe:user-settings'
const DOCKS_ACTIVATE_EVENT = 'devframe:docks:activate'

export interface DevframeClientHostOptions {
/**
Expand Down Expand Up @@ -124,6 +125,32 @@ export async function createDevframeClientHost(
reconcileEntries()
disposers.push(docksState.on('updated', reconcileEntries))

// Honor cross-iframe dock activation: any client (e.g. a mounted devframe in
// its own iframe) can ask the hub to switch this shell's active dock via the
// `hub:docks:activate` RPC, which the hub broadcasts here. `switchEntry`
// ignores ids it doesn't recognize, so an unknown target degrades to a no-op.
// Another consumer sharing this rpc client may have registered the handler
// already — chain onto it rather than replacing it.
const activateHandler = (activation: { dockId?: string } | undefined): void => {
if (activation?.dockId)
void switchEntry(activation.dockId)
}
const existingActivate = rpc.client.definitions.get(DOCKS_ACTIVATE_EVENT)
if (existingActivate) {
const prev = existingActivate.handler
existingActivate.handler = (...args: unknown[]) => {
activateHandler(args[0] as { dockId?: string })
return prev?.(...args)
}
}
else {
rpc.client.register({
name: DOCKS_ACTIVATE_EVENT,
type: 'action',
handler: (activation: { dockId?: string }) => activateHandler(activation),
})
}

if (getDevframeClientContext()) {
console.warn(
'[@devframes/hub] A client host context is already published on this page — replacing it. '
Expand Down
33 changes: 32 additions & 1 deletion packages/hub/src/node/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHostContext, startHttpAndWs } from 'devframe/node'
import { getInternalContext } from 'devframe/node/hub-internals'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createHubContext } from '../context'

function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) {
Expand All @@ -28,6 +28,37 @@ describe('createHubContext shared state', () => {
})
})

describe('createHubContext dock activation', () => {
it('mirrors an activation into shared state and broadcasts it live', async () => {
const context = await createHubContext({
cwd: process.cwd(),
mode: 'build',
host: createHost(),
})
context.docks.register({
type: 'iframe',
id: 'devframes-plugin-terminals',
title: 'Terminals',
icon: 'ph:terminal-window-duotone',
url: '/__devframes-plugin-terminals/',
})

const broadcast = vi.spyOn(context.rpc, 'broadcast').mockResolvedValue()
context.docks.activate('devframes-plugin-terminals', { sessionId: 'sess-1' })

const active = await context.rpc.sharedState.get<{ activation: unknown }>('devframe:docks:active')
expect(active.value().activation).toEqual({
dockId: 'devframes-plugin-terminals',
params: { sessionId: 'sess-1' },
})
expect(broadcast).toHaveBeenCalledWith({
method: 'devframe:docks:activate',
args: [{ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } }],
})
broadcast.mockRestore()
})
})

describe('startHttpAndWs remote endpoint metadata', () => {
it('sets and clears the internal websocket endpoint', async () => {
const context = await createHostContext({
Expand Down
32 changes: 31 additions & 1 deletion packages/hub/src/node/__tests__/host-docks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { REMOTE_CONNECTION_KEY } from 'devframe/constants'
import { getInternalContext } from 'devframe/node/hub-internals'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { parseRemoteConnection } from '../../client/remote'
import { DevframeDocksHost } from '../host-docks'

Expand Down Expand Up @@ -190,6 +190,36 @@ describe('devframeDockHost grouping', () => {
})
})

describe('devframeDockHost activate', () => {
it('emits a dock:activate event carrying the id and params', () => {
const host = new DevframeDocksHost(createContext())
host.register({ type: 'iframe', id: 'terminals', title: 'Terminals', icon: 'ph:terminal-window-duotone', url: '/__terminals/' })

const activations: Array<{ dockId: string, params?: Record<string, unknown> }> = []
host.events.on('dock:activate', a => activations.push(a))

host.activate('terminals', { sessionId: 'sess-1' })
expect(activations).toEqual([{ dockId: 'terminals', params: { sessionId: 'sess-1' } }])
})

it('still emits for an unknown dock but warns (DF8107, graceful)', () => {
const host = new DevframeDocksHost(createContext())
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const activations: string[] = []
host.events.on('dock:activate', a => activations.push(a.dockId))

host.activate('nope')
expect(activations).toEqual(['nope'])
expect(warn).toHaveBeenCalledOnce()
expect(warn.mock.calls[0]!.join(' ')).toMatch(/unknown dock id/i)
}
finally {
warn.mockRestore()
}
})
})

describe('devframeDockHost ~builtin category', () => {
it('returns no docks until an integration registers one', () => {
const host = new DevframeDocksHost(createContext())
Expand Down
22 changes: 21 additions & 1 deletion packages/hub/src/node/__tests__/rpc-builtins.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DevframeHubContext } from '../context'
import { describe, expect, it, vi } from 'vitest'
import { hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'
import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'

function contextWithSessions(sessions: Map<string, any>): DevframeHubContext {
return { terminals: { sessions } } as unknown as DevframeHubContext
Expand Down Expand Up @@ -37,3 +37,23 @@ describe('hub terminal write/resize RPC', () => {
await expect(writeFn.handler!('nope', 'x')).rejects.toThrow(/not registered/i)
})
})

describe('hub docks activate RPC', () => {
it('forwards dockId and params to docks.activate', async () => {
const activate = vi.fn()
const ctx = { docks: { activate } } as unknown as DevframeHubContext

const fn = await hubDocksActivate.setup!(ctx)
await fn.handler!({ dockId: 'devframes-plugin-terminals', params: { sessionId: 'sess-1' } })
expect(activate).toHaveBeenCalledWith('devframes-plugin-terminals', { sessionId: 'sess-1' })
})

it('forwards a bare dockId without params', async () => {
const activate = vi.fn()
const ctx = { docks: { activate } } as unknown as DevframeHubContext

const fn = await hubDocksActivate.setup!(ctx)
await fn.handler!({ dockId: 'devframes-plugin-messages' })
expect(activate).toHaveBeenCalledWith('devframes-plugin-messages', undefined)
})
})
32 changes: 31 additions & 1 deletion packages/hub/src/node/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CreateHostContextOptions } from 'devframe/node'
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
import type { DevframeCommandsHost } from '../types/commands'
import type { DevframeDocksHost } from '../types/docks'
import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks'
import type { JsonRenderer, JsonRenderSpec } from '../types/json-render'
import type { DevframeMessagesHost } from '../types/messages'
import type { DevframeTerminalsHost } from '../types/terminals'
Expand All @@ -15,6 +15,17 @@ import { builtinHubRpcDeclarations } from './rpc-builtins'

declare module 'devframe/types' {
interface DevframeRpcClientFunctions {
/**
* Server→client request to switch the active dock. Broadcast by the hub
* context in response to `ctx.docks.activate()` (driven by the
* `hub:docks:activate` RPC). The client host registers a handler that
* calls its local `switchEntry(dockId)`; the target dock reads
* `activation.params` to react (e.g. focus a session). Do not register
* manually.
*
* @internal
*/
'devframe:docks:activate': (activation: DevframeDockActivation) => Promise<void>
/**
* Server→client notification that terminal sessions changed. Broadcast
* by the hub context; a hub-aware client re-reads terminal state in
Expand Down Expand Up @@ -123,6 +134,25 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
docks.events.on('dock:entry:updated', refreshDocks)
docksSharedState.mutate(() => docks.values())

// Cross-iframe dock activation. A dock activation is a discrete user intent
// ("go to Terminals now"), so it fires immediately (no debounce, which could
// coalesce two distinct requests) both as a live broadcast — the host shell
// switches its active dock — and into a shared-state slot, so a dock that
// only mounts *because* of the switch still converges on the request.
const activeDockSharedState = await context.rpc.sharedState.get<DevframeDocksActiveState>(
'devframe:docks:active',
{ initialValue: { activation: null } },
)
docks.events.on('dock:activate', (activation) => {
activeDockSharedState.mutate((state) => {
state.activation = activation
})
context.rpc.broadcast({
method: 'devframe:docks:activate',
args: [activation],
})
})

const broadcastTerminals = debounce(() => {
context.rpc.broadcast({
method: 'devframe:terminals:updated',
Expand Down
Loading
Loading