diff --git a/docs/guide/hub.md b/docs/guide/hub.md index e2964d02..45d681c0 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -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. @@ -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)`: diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index ae9ef6bf..ec69058f 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -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)('hub:commands:execute', id, ...args) + return rpc.call('hub:commands:execute', id, ...args) }, getKeybindings(id): DevframeCommandKeybinding[] { const override = settings.value().commandShortcuts?.[id] diff --git a/packages/hub/src/client/messages.ts b/packages/hub/src/client/messages.ts index da20601d..5f563b0f 100644 --- a/packages/hub/src/client/messages.ts +++ b/packages/hub/src/client/messages.ts @@ -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 + const { call } = rpc function makeHandle(entry: DevframeMessageEntry): DevframeMessageHandle { let current = entry @@ -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, + dismiss: () => call('hub:messages:remove', current.id), } } async function add(input: DevframeMessageEntryInput): Promise { - 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) } @@ -58,8 +57,8 @@ export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesCl return { add, - remove: id => call('hub:messages:remove', id) as Promise, - clear: () => call('hub:messages:clear') as Promise, + remove: id => call('hub:messages:remove', id), + clear: () => call('hub:messages:clear'), info: levelShortcut('info'), warn: levelShortcut('warn'), error: levelShortcut('error'), diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 61753d68..1db4d7e7 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -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') + }) }) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index 7ccd870a..834601b1 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -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() diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 2ad20594..c6f14630 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -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' @@ -43,6 +43,43 @@ declare module 'devframe/types' { */ 'devframe:messages:updated': () => Promise } + + 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 }) => Promise + /** + * 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 + /** + * 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 + /** Patch a message by id; resolves the updated entry (or `undefined`). */ + 'hub:messages:update': (id: string, patch: Partial) => Promise + /** Remove a message by id. */ + 'hub:messages:remove': (id: string) => Promise + /** Remove every message. */ + 'hub:messages:clear': () => Promise + /** + * 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 + /** Resize an interactive PTY session by id. */ + 'hub:terminals:resize': (id: string, cols: number, rows: number) => Promise + } } /** diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index ed24ff3f..4857a01f 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -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) @@ -213,6 +225,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { function createChildProcess() { const currentRun = ++runId + let runErrored = false const cp = exec( executeOptions.command, executeOptions.args || [], @@ -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 = { @@ -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, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 660f2e6c..c3e5009b 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -194,7 +194,36 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase { description?: string buttonStart?: string buttonLoading?: string - onLaunch: () => Promise + /** + * Bound command id: the launch button, command palette entry, and any + * keybinding all resolve to this one handler. A viewer running out of + * process dispatches it over the `hub:commands:execute` RPC — the + * serializable path {@link onLaunch} can't cross, since a function is + * dropped when the entry is projected into the `devframe:docks` shared + * state. Register the command (with its handler) via `ctx.commands`. + */ + command?: string + /** + * Id of the terminal session this launcher tracks (e.g. the one returned + * by `ctx.terminals.startChildProcess`). A viewer surfaces a first-class + * "view in terminal" action that calls `hub:docks:activate` with the + * terminals dock id and `{ sessionId: terminalSessionId }`, jumping the + * user straight to the running process. + */ + terminalSessionId?: string + /** + * Latest single line of progress for inline display beneath the launcher + * (e.g. the tail of the tracked session's output). Author-set: the owner + * patches it via `docks.update()` as the process reports progress. + */ + digest?: string + /** + * In-process launch handler. Optional: a same-process host can invoke it + * directly, but it does not survive projection into shared state, so an + * out-of-process viewer relies on {@link command} instead. Provide one or + * both. + */ + onLaunch?: () => Promise } }