Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/content/1.guide/20.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ Each subsystem host emits on `ctx.<subsystem>.events`, consumed **inside the sam
|---|---|---|---|
| `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` |
| `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` |
| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` |
| `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; terminals plugin | `DevframeTerminalSession` |
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | entry / entry / id / — |
| `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id |

`docks:panel:state` emits `connected` with the first reported `open` value, `changed` when that value changes, and `disconnected` when the reporting RPC connection closes. Its numeric `sessionId` identifies that connection for the lifetime of the Node process. A reload or reconnect receives a new id.

### Server RPC methods — client → server

| Method | Signature | Purpose |
|---|---|---|
| `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](/guide/deep-linking). |
| `hub:docks:panel:state` | `(open) => void` | Report this viewer connection's current dock-panel state. |
| `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. |
| `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). |
| `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. |
Expand Down
33 changes: 33 additions & 0 deletions packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import { HUB_EVENTS } from '@devframes/hub/constants'
import { DEVFRAME_EVENTS } from 'devframe/constants'
import { createEventEmitter } from 'devframe/utils/events'
import { createSharedState } from 'devframe/utils/shared-state'
Expand Down Expand Up @@ -74,6 +75,38 @@ async function flushRestore(): Promise<void> {
}

describe('createDocksContext', () => {
it('reports the restored panel state and later open-state transitions', async () => {
expect.assertions(4)

const { rpc, sharedStates, trust } = createStubRpc()
const session = ref<DockSessionStorage>({
open: true,
selectedDockId: 'git',
selectedDockRoute: null,
})
await createDocksContext('embedded', rpc, undefined, session)

trust()
sharedStates.get('devframe:docks')!.push([gitEntry])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()
await vi.waitFor(() => {
if (vi.mocked(rpc.call).mock.calls.length !== 1)
throw new Error('waiting for the restored panel state report')
})

expect(rpc.call).toHaveBeenCalledTimes(1)
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true)

session.value.open = false
await nextTick()
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false)

session.value.open = false
await nextTick()
expect(rpc.call).toHaveBeenCalledTimes(2)
})

it('mounts a restored dock once after all initial server state arrives', async () => {
expect.assertions(7)

Expand Down
16 changes: 13 additions & 3 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
import type { WhenContext } from 'devframe/utils/when'
import type { Ref } from 'vue'
import type { DevframeDocksUserSettings } from './dock-settings'
import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client'
import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client'
import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants'
import { DEVFRAME_EVENTS } from 'devframe/constants'
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
Expand Down Expand Up @@ -637,12 +637,14 @@ export async function createDocksContext(
// the captured session intent.
// `switchEntry` then consumes the persisted iframe route when the view boots.
const restoreAfterInitialization = async (): Promise<void> => {
// The authorization gate can still clear the live session on reload, so restore only after it settles.
await waitUntilTrusted()

const restoreDockId = restoreIntent.selectedDockId
if (!restoreIntent.open || restoreDockId == null)
return

await Promise.all([
waitUntilTrusted(),
dockEntriesInitialSyncComplete,
rendererManifestInitialSyncComplete,
])
Expand All @@ -658,7 +660,15 @@ export async function createDocksContext(
initialRestorePending.value = false
await switchEntry(restoreDockId)
}
void restoreAfterInitialization()
const reportPanelStateAfterInitialization = async (): Promise<void> => {
await restoreAfterInitialization()
watch(
() => sessionStore.value.open,
open => void reportDockPanelState(rpc, open).catch(() => {}),
{ immediate: true },
)
}
void reportPanelStateAfterInitialization()

docksContextByRpc.set(rpc, docksContext)
return docksContext
Expand Down
21 changes: 21 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
import type { DevframeDockEntry } from '../../types/docks'
import { createEventEmitter } from 'devframe/utils/events'
import { describe, expect, it, vi } from 'vitest'
import { HUB_EVENTS } from '../../events'
import { getDevframeClientContext } from '../context'
import { createDevframeClientHost } from '../host'

Expand Down Expand Up @@ -67,6 +68,26 @@ function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEn
}

describe('createDevframeClientHost', () => {
it('reports its initial panel state and later open-state assignments', async () => {
expect.assertions(3)

const { rpc, calls } = createStubRpc()
const host = await createDevframeClientHost({ rpc, clientType: 'embedded' })

expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]])

host.context.panel.session.open = true
host.context.panel.session.open = true
expect(calls).toEqual([
[HUB_EVENTS.rpc.docksPanelState, false],
[HUB_EVENTS.rpc.docksPanelState, true],
])

host.context.panel.session.open = false
expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false])
host.dispose()
})

it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
Expand Down
24 changes: 20 additions & 4 deletions packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { HUB_EVENTS } from '../events'
import { getDevframeClientContext, setDevframeClientContext } from './context'
import { attachFrameNavClient } from './frame-nav'
import { createMessagesClient } from './messages'
import { reportDockPanelState } from './panel-state'
import { createDockRenderersContext } from './renderers'

const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks
Expand Down Expand Up @@ -154,7 +155,10 @@ export async function createDevframeClientHost(
...options.categoryOrder,
}

const panel = createPanelContext(clientType)
const sendPanelState = (open: boolean): void => {
void reportDockPanelState(rpc, open).catch(() => {})
}
const panel = createPanelContext(clientType, sendPanelState)
const docks = createDocksContext()
const commands = createCommandsContext()
const renderers = createRenderersContext()
Expand Down Expand Up @@ -225,6 +229,7 @@ export async function createDevframeClientHost(
)
}
setDevframeClientContext(context)
sendPanelState(panel.session.open)

const loadedScripts = new Set<string>()
if (loadScriptsEnabled) {
Expand Down Expand Up @@ -549,7 +554,10 @@ export async function createDevframeClientHost(

// ── shared helpers ─────────────────────────────────────────────────────────

function createPanelContext(clientType: DockClientType): DocksPanelContext {
function createPanelContext(
clientType: DockClientType,
onOpenChange: (open: boolean) => void,
): DocksPanelContext {
const store: DocksPanelContext['store'] = {
mode: 'edge',
width: 480,
Expand All @@ -559,9 +567,17 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext {
position: 'right',
inactiveTimeout: 0,
}
let open = clientType === 'standalone'
const session: DocksPanelContext['session'] = {
// A standalone runtime owns the page, so its "panel" is always open.
open: clientType === 'standalone',
get open() {
return open
},
set open(nextOpen) {
if (nextOpen === open)
return
open = nextOpen
onOpenChange(open)
},
selectedDockId: null,
selectedDockRoute: null,
}
Expand Down
1 change: 1 addition & 0 deletions packages/hub/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './frame-location'
export * from './frame-nav'
export * from './host'
export * from './messages'
export * from './panel-state'
export * from './remote'
export * from './renderers'
export * from 'devframe/client'
10 changes: 10 additions & 0 deletions packages/hub/src/client/panel-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { DevframeRpcClient } from 'devframe/client'
import { HUB_EVENTS } from '../events'

/** Report this RPC connection's current dock-panel state to the hub. */
export async function reportDockPanelState(
rpc: DevframeRpcClient,
open: boolean,
): Promise<void> {
await rpc.call(HUB_EVENTS.rpc.docksPanelState, open)
}
2 changes: 2 additions & 0 deletions packages/hub/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const HUB_EVENTS = {
bus: {
docksEntryUpdated: 'docks:entry:updated',
docksActivate: 'docks:activate',
docksPanelState: 'docks:panel:state',
terminalsSessionUpdated: 'terminals:session:updated',
messagesAdded: 'messages:added',
messagesUpdated: 'messages:updated',
Expand All @@ -34,6 +35,7 @@ export const HUB_EVENTS = {
/** Server RPC methods a connected client calls (client → server), `hub:` prefix. */
rpc: {
docksActivate: 'hub:docks:activate',
docksPanelState: 'hub:docks:panel:state',
commandsExecute: 'hub:commands:execute',
messagesAdd: 'hub:messages:add',
messagesUpdate: 'hub:messages:update',
Expand Down
43 changes: 42 additions & 1 deletion packages/hub/src/node/__tests__/host-docks.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DevframeViewLauncher } from '../../types/docks'
import type { DevframeDockPanelStateEvent, DevframeViewLauncher } from '../../types/docks'
import type { DevframeHubContext } from '../context'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
Expand All @@ -7,7 +7,9 @@ import { REMOTE_CONNECTION_KEY } from 'devframe/constants'
import { getInternalContext } from 'devframe/node/hub-internals'
import { describe, expect, it, vi } from 'vitest'
import { parseRemoteConnection } from '../../client/remote'
import { HUB_EVENTS } from '../../events'
import { DevframeDocksHost } from '../host-docks'
import { disconnectDockPanelState, updateDockPanelState } from '../panel-state'

function createContext(): DevframeHubContext {
const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-'))
Expand Down Expand Up @@ -221,6 +223,45 @@ describe('devframeDockHost activate', () => {
})
})

describe('devframeDockHost panel state', () => {
it('emits the first report and changed values while suppressing duplicates', () => {
expect.assertions(1)

const host = new DevframeDocksHost(createContext())
const events: DevframeDockPanelStateEvent[] = []
host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event))

updateDockPanelState(host, 11, false)
updateDockPanelState(host, 11, false)
updateDockPanelState(host, 11, true)

expect(events).toEqual([
{ type: 'connected', sessionId: 11, open: false },
{ type: 'changed', sessionId: 11, open: true },
])
})

it('tracks sessions independently and disconnects only reporting sessions', () => {
expect.assertions(1)

const host = new DevframeDocksHost(createContext())
const events: DevframeDockPanelStateEvent[] = []
host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event))

updateDockPanelState(host, 11, true)
updateDockPanelState(host, 12, false)
disconnectDockPanelState(host, 99)
disconnectDockPanelState(host, 11)
disconnectDockPanelState(host, 11)

expect(events).toEqual([
{ type: 'connected', sessionId: 11, open: true },
{ type: 'connected', sessionId: 12, open: false },
{ type: 'disconnected', sessionId: 11 },
])
})
})

describe('devframeDockHost ~builtin category', () => {
it('returns no docks until an integration registers one', () => {
const host = new DevframeDocksHost(createContext())
Expand Down
67 changes: 64 additions & 3 deletions packages/hub/src/node/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
import type { DevframeDockPanelStateEvent } from '../../types/docks'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { getPort } from 'get-port-please'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { DOCK_RENDERERS_STATE_KEY } from '../../constants'
import { HUB_EVENTS } from '../../events'
import { DEVFRAMES_HUB_BASE, initHub } from '../initiate'

function makeDist(html: string): string {
Expand Down Expand Up @@ -38,10 +40,12 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition {
}

function connectWsClient(url: string) {
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
const channel = createWsRpcChannel({ url })
const client = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
{} as DevframeRpcClientFunctions,
{ channel: createWsRpcChannel({ url }) },
{ channel },
)
return Object.assign(client, { close: channel.close })
}

describe('initHub', () => {
Expand Down Expand Up @@ -152,6 +156,63 @@ describe('initHub', () => {
}
})

it('tracks panel state by RPC connection and emits disconnect separately from close', async () => {
expect.assertions(9)

const host = '127.0.0.1'
const port = await getPort({ port: 18215, host })
const hub = initHub({
base: DEVFRAMES_HUB_BASE,
auth: false,
host,
ws: { port },
devframes: [makeFrame('alpha')],
})
const clients: ReturnType<typeof connectWsClient>[] = []

try {
await hub.ready
const context = await hub.context
const lifecycleEvents: DevframeDockPanelStateEvent[] = []
context.docks.events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event))

const firstClient = connectWsClient(`ws://${host}:${port}/__ws`)
const secondClient = connectWsClient(`ws://${host}:${port}/__ws`)
clients.push(firstClient, secondClient)

await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true)
await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true)
await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, false)
await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, false)

expect(lifecycleEvents).toHaveLength(3)
expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true })
expect(typeof lifecycleEvents[0]!.sessionId).toBe('number')
expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, open: false })
expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', open: false })
expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId)

firstClient.close()
await vi.waitFor(() => {
if (lifecycleEvents.length !== 4)
throw new Error('waiting for the first client to disconnect')
})
expect(lifecycleEvents[3]).toEqual({ type: 'disconnected', sessionId: lifecycleEvents[0]!.sessionId })

const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`)
clients.push(reconnectedClient)
await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true)

expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true })
expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId)
}
finally {
for (const client of clients)
client.close()
await hub.close()
}
})

it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => {
const viewerDist = makeDist('<!doctype html><title>hub viewer</title>')
const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-'))
Expand Down
Loading
Loading