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
50 changes: 49 additions & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ Plus a `createJsonRenderer(spec)` factory for building remote-UI panels via the

## Built-in RPC

Every hub context auto-registers these RPC functions so framework kits don't reimplement them:
Every hub context auto-registers these RPC functions so framework kits don't reimplement them. Each id is declared on the client-callable surface, so `rpc.call(...)` type-checks the arguments and return value:

- `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).
- `hub:messages:add` / `update` / `remove` / `clear` — write into the messages feed from a browser client.
- `hub:terminals:write` / `resize` — drive an interactive PTY session by id.

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

Expand All @@ -46,6 +48,52 @@ This is what lets Vite DevTools' Rolldown analyzer spawn a `vite build` via `ctx

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

## Process-control launchers

A `type: 'launcher'` dock entry is a one-click action tile — "run this build", "start this server". Three optional fields on `launcher` turn it into a live process controller that binds a command, streams progress, and navigates to its terminal:

| Field | Purpose |
|---|---|
| `command` | Bound command id. The launch button, its command-palette entry, and any keybinding all resolve to this one handler. A viewer running out of process dispatches it over `hub:commands:execute` — the serializable path, since a function is dropped when the entry is projected into `devframe:docks`. Register the command (with its handler) via `ctx.commands`. |
| `terminalSessionId` | Id of the terminal session the launcher tracks. A viewer surfaces a "view in terminal" action that calls `hub:docks:activate` with the terminals dock id and `{ sessionId }`, jumping straight to the running process. |
| `digest` | Latest single line of progress, shown inline beneath the launcher. Author-set: patch it via `docks.update()` as the process reports progress. |

`onLaunch` remains for a same-process host to invoke directly; provide `command`, `onLaunch`, or both.

```ts
ctx.commands.register({ id: 'app:build', title: 'Run build', handler: runBuild })

const launcher = ctx.docks.register({
type: 'launcher',
id: 'app:build',
title: 'Build',
icon: 'ph:hammer-duotone',
launcher: { title: 'Run build', command: 'app:build', status: 'idle' },
})

async function runBuild() {
const session = await ctx.terminals.startChildProcess(
{ command: 'vite', args: ['build'] },
{ id: 'app:build-session', title: 'vite build' },
)
launcher.update({ launcher: { title: 'Run build', command: 'app:build', status: 'loading', terminalSessionId: session.id } })

// A child-process session keeps its `status` live: `running` → `stopped` on a
// clean exit, `error` on a non-zero exit or spawn failure. Map it onto the
// launcher and read the exit code from getResult().
const { exitCode } = await session.getResult()
launcher.update({ launcher: {
title: 'Run build',
command: 'app:build',
terminalSessionId: session.id,
status: exitCode === 0 ? 'success' : 'error',
error: exitCode === 0 ? undefined : `vite build exited ${exitCode}`,
} })
}
```

This is what lets a downstream analyzer spawn a `vite build`, show its progress inline, and navigate the user straight to that build's terminal session.

## 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
2 changes: 1 addition & 1 deletion packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export async function createDevframeClientHost(
if (client?.action)
return client.action(...args)
// Server command — dispatch through the hub built-in.
return (rpc.call as (name: string, ...a: any[]) => Promise<unknown>)('hub:commands:execute', id, ...args)
return rpc.call('hub:commands:execute', id, ...args)
},
getKeybindings(id): DevframeCommandKeybinding[] {
const override = settings.value().commandShortcuts?.[id]
Expand Down
13 changes: 6 additions & 7 deletions packages/hub/src/client/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ export interface MessagesClientOptions {
* dock client script reports into the very feed the server writes to.
*/
export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesClientOptions = {}): DevframeMessagesClient {
// The `hub:messages:*` ids aren't in the statically-typed server map.
const call = rpc.call as (name: string, ...args: any[]) => Promise<any>
const { call } = rpc

function makeHandle(entry: DevframeMessageEntry): DevframeMessageHandle {
let current = entry
Expand All @@ -37,17 +36,17 @@ export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesCl
return current.id
},
async update(patch) {
const updated = await call('hub:messages:update', current.id, patch) as DevframeMessageEntry | undefined
const updated = await call('hub:messages:update', current.id, patch)
if (updated)
current = updated
return updated
},
dismiss: () => call('hub:messages:remove', current.id) as Promise<void>,
dismiss: () => call('hub:messages:remove', current.id),
}
}

async function add(input: DevframeMessageEntryInput): Promise<DevframeMessageHandle> {
const entry = await call('hub:messages:add', { ...options.defaults, ...input }) as DevframeMessageEntry
const entry = await call('hub:messages:add', { ...options.defaults, ...input })
return makeHandle(entry)
}

Expand All @@ -58,8 +57,8 @@ export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesCl

return {
add,
remove: id => call('hub:messages:remove', id) as Promise<void>,
clear: () => call('hub:messages:clear') as Promise<void>,
remove: id => call('hub:messages:remove', id),
clear: () => call('hub:messages:clear'),
info: levelShortcut('info'),
warn: levelShortcut('warn'),
error: levelShortcut('error'),
Expand Down
40 changes: 40 additions & 0 deletions packages/hub/src/node/__tests__/host-docks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,44 @@ describe('devframeDockHost ~builtin category', () => {

expect(host.values()[0].category).toBeUndefined()
})

it('carries a launcher bound to a command + terminal session with a digest, without onLaunch', () => {
const host = new DevframeDocksHost(createContext())
host.register({
type: 'launcher',
id: 'app:build',
title: 'Build',
icon: 'ph:hammer-duotone',
launcher: {
title: 'Run build',
status: 'loading',
command: 'app:run-build',
terminalSessionId: 'build-session',
digest: 'compiling…',
},
})

const entry = host.values()[0]
expect(entry.type).toBe('launcher')
const launcher = entry.type === 'launcher' ? entry.launcher : undefined
expect(launcher).toMatchObject({
command: 'app:run-build',
terminalSessionId: 'build-session',
digest: 'compiling…',
})
expect(launcher?.onLaunch).toBeUndefined()

// Progress patches flow through update() like any other entry field.
host.update({
type: 'launcher',
id: 'app:build',
title: 'Build',
icon: 'ph:hammer-duotone',
launcher: { ...launcher!, digest: 'done', status: 'success' },
})
const updated = host.values()[0]
const updatedLauncher = updated.type === 'launcher' ? updated.launcher : undefined
expect(updatedLauncher?.digest).toBe('done')
expect(updatedLauncher?.status).toBe('success')
})
})
75 changes: 75 additions & 0 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,81 @@ describe('devframeTerminalHost stream lifecycle', () => {
})
})

describe('devframeTerminalHost child-process status lifecycle', () => {
it('marks status stopped and emits an update on a clean exit', async () => {
const { host } = createTerminalHost()
const updates: string[] = []
host.events.on('terminal:session:updated', s => updates.push(s.status))

const session = await host.startChildProcess({
command: process.execPath,
args: ['-e', 'process.exit(0)'],
}, { id: 'child', title: 'Child' })

await waitUntil(() => {
expect(session.status).toBe('stopped')
})
expect(updates).toContain('stopped')
})

it('marks status error on a non-zero exit', async () => {
const { host } = createTerminalHost()

const session = await host.startChildProcess({
command: process.execPath,
args: ['-e', 'process.exit(3)'],
}, { id: 'child', title: 'Child' })

await waitUntil(() => {
expect(session.status).toBe('error')
})
})

it('marks status error when the process fails to spawn', async () => {
const { host } = createTerminalHost()

const session = await host.startChildProcess({
command: 'this-command-does-not-exist-df',
args: [],
}, { id: 'child', title: 'Child' })

await waitUntil(() => {
expect(session.status).toBe('error')
})
})

it('marks status stopped on terminate() rather than error', async () => {
const { host, sinks } = createTerminalHost()

const session = await host.startChildProcess({
command: process.execPath,
args: ['-e', 'setInterval(() => {}, 1000)'],
}, { id: 'child', title: 'Child' })

expect(session.status).toBe('running')
await session.terminate()

await waitUntil(() => {
expect(sinks.get('child')?.closed).toBe(true)
})
expect(session.status).toBe('stopped')
})

it('returns to running after restart() without an error flash', async () => {
const { host } = createTerminalHost()

const session = await host.startChildProcess({
command: process.execPath,
args: ['-e', 'setInterval(() => {}, 1000)'],
}, { id: 'child', title: 'Child' })

await session.restart()
expect(session.status).toBe('running')

await session.terminate()
})
})

describe('devframeTerminalHost interactive PTY sessions', () => {
itPosixPty('spawns an interactive PTY that accepts input and is marked interactive', async () => {
const { host, sinks } = createTerminalHost()
Expand Down
39 changes: 38 additions & 1 deletion packages/hub/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
import type { DevframeCommandsHost } from '../types/commands'
import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks'
import type { JsonRenderer, JsonRenderSpec } from '../types/json-render'
import type { DevframeMessagesHost } from '../types/messages'
import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesHost } from '../types/messages'
import type { DevframeTerminalsHost } from '../types/terminals'
import { createHostContext } from 'devframe/node'
import { debounce } from 'perfect-debounce'
Expand Down Expand Up @@ -43,6 +43,43 @@ declare module 'devframe/types' {
*/
'devframe:messages:updated': () => Promise<void>
}

interface DevframeRpcServerFunctions {
/**
* Ask the active viewer to switch its focused dock to `dockId`, optionally
* carrying `params` for the target dock to interpret (e.g.
* `{ sessionId }` for the terminals dock). Any connected client may call
* it — a mounted devframe in its own iframe steers the host shell's dock
* selection. Handled by {@link import('./rpc-builtins').hubDocksActivate}.
*/
'hub:docks:activate': (input: { dockId: string, params?: Record<string, unknown> }) => Promise<void>
/**
* Invoke a registered server command by id; trailing args are forwarded to
* the command's handler. Handled by
* {@link import('./rpc-builtins').hubCommandsExecute}.
*/
'hub:commands:execute': (id: string, ...args: any[]) => Promise<unknown>
/**
* Add a message from a browser client into the hub's messages feed
* (marked `from: 'browser'`); returns the serializable entry. Handled by
* {@link import('./rpc-builtins').hubMessagesAdd}.
*/
'hub:messages:add': (input: DevframeMessageEntryInput) => Promise<DevframeMessageEntry>
/** Patch a message by id; resolves the updated entry (or `undefined`). */
'hub:messages:update': (id: string, patch: Partial<DevframeMessageEntryInput>) => Promise<DevframeMessageEntry | undefined>
/** Remove a message by id. */
'hub:messages:remove': (id: string) => Promise<void>
/** Remove every message. */
'hub:messages:clear': () => Promise<void>
/**
* Send input to an interactive PTY session spawned via
* `ctx.terminals.startPtySession`. Handled by
* {@link import('./rpc-builtins').hubTerminalsWrite}.
*/
'hub:terminals:write': (id: string, data: string) => Promise<void>
/** Resize an interactive PTY session by id. */
'hub:terminals:resize': (id: string, cols: number, rows: number) => Promise<void>
}
}

/**
Expand Down
29 changes: 26 additions & 3 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
let currentResult: DevframeChildProcessResult | undefined
let runId = 0
let streamClosed = false
let session: DevframeChildProcessTerminalSession

// Keep the registered session's `status` in step with the process
// lifecycle so a hub-aware client (and any launcher tracking this session)
// sees `running` → `stopped`/`error` transitions instead of a value frozen
// at spawn time.
const markStatus = (next: DevframeTerminalSession['status']): void => {
if (session.status === next)
return
session.status = next
this.events.emit('terminal:session:updated', session)
}

const closeStream = () => {
if (streamClosed)
Expand Down Expand Up @@ -213,6 +225,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {

function createChildProcess() {
const currentRun = ++runId
let runErrored = false
const cp = exec(
executeOptions.command,
executeOptions.args || [],
Expand Down Expand Up @@ -271,13 +284,21 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
cp.process?.once('error', (error) => {
if (currentRun !== runId)
return
runErrored = true
settle(cp.process?.exitCode ?? undefined)
errorStream(error)
markStatus('error')
})
cp.process?.once('close', (code) => {
settle(code ?? undefined)
if (currentRun === runId)
closeStream()
if (currentRun !== runId)
return
closeStream()
// A spawn/runtime error already settled the status; a non-zero exit
// code is a crash. A clean exit, or a signal kill (no numeric code —
// e.g. terminate()/restart()), is a deliberate/normal stop.
if (!runErrored)
markStatus(typeof code === 'number' && code !== 0 ? 'error' : 'stopped')
})

currentResult = {
Expand All @@ -304,14 +325,16 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
return
cp?.kill()
cp = createChildProcess()
markStatus('running')
}
const terminate = async () => {
cp?.kill()
cp = undefined
closeStream()
markStatus('stopped')
}

const session: DevframeChildProcessTerminalSession = {
session = {
...terminal,
status: 'running',
stream,
Expand Down
Loading
Loading