Skip to content

Commit 2900b4a

Browse files
committed
feat(hub): expose panel session lifecycle events
1 parent 938b222 commit 2900b4a

23 files changed

Lines changed: 348 additions & 11 deletions

docs/content/1.guide/20.events.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@ Each subsystem host emits on `ctx.<subsystem>.events`, consumed **inside the sam
1919
|---|---|---|---|
2020
| `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` |
2121
| `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` |
22+
| `docks:panel:state` | viewer state reports and RPC disconnects | hub consumers | `DevframeDockPanelStateEvent` |
2223
| `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; terminals plugin | `DevframeTerminalSession` |
2324
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | entry / entry / id / — |
2425
| `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id |
2526

27+
`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.
28+
2629
### Server RPC methods — client → server
2730

2831
| Method | Signature | Purpose |
2932
|---|---|---|
3033
| `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](/guide/deep-linking). |
34+
| `hub:docks:panel-state` | `(open) => void` | Report this viewer connection's current dock-panel state. |
3135
| `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. |
3236
| `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). |
3337
| `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. |

packages/hub-ui/src/client/state/context.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
22
import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
4+
import { HUB_EVENTS } from '@devframes/hub/constants'
45
import { DEVFRAME_EVENTS } from 'devframe/constants'
56
import { createEventEmitter } from 'devframe/utils/events'
67
import { createSharedState } from 'devframe/utils/shared-state'
@@ -74,6 +75,38 @@ async function flushRestore(): Promise<void> {
7475
}
7576

7677
describe('createDocksContext', () => {
78+
it('reports the restored panel state and later open-state transitions', async () => {
79+
expect.assertions(4)
80+
81+
const { rpc, sharedStates, trust } = createStubRpc()
82+
const session = ref<DockSessionStorage>({
83+
open: true,
84+
selectedDockId: 'git',
85+
selectedDockRoute: null,
86+
})
87+
await createDocksContext('embedded', rpc, undefined, session)
88+
89+
trust()
90+
sharedStates.get('devframe:docks')!.push([gitEntry])
91+
sharedStates.get('devframe:dock-renderers')!.push({})
92+
await flushRestore()
93+
await vi.waitFor(() => {
94+
if (vi.mocked(rpc.call).mock.calls.length !== 1)
95+
throw new Error('waiting for the restored panel state report')
96+
})
97+
98+
expect(rpc.call).toHaveBeenCalledTimes(1)
99+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, true)
100+
101+
session.value.open = false
102+
await nextTick()
103+
expect(rpc.call).toHaveBeenLastCalledWith(HUB_EVENTS.rpc.docksPanelState, false)
104+
105+
session.value.open = false
106+
await nextTick()
107+
expect(rpc.call).toHaveBeenCalledTimes(2)
108+
})
109+
77110
it('mounts a restored dock once after all initial server state arrives', async () => {
78111
expect.assertions(7)
79112

packages/hub-ui/src/client/state/context.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
44
import type { WhenContext } from 'devframe/utils/when'
55
import type { Ref } from 'vue'
66
import type { DevframeDocksUserSettings } from './dock-settings'
7-
import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client'
7+
import { attachFrameNavClient, createDockRenderersContext, reportDockPanelState } from '@devframes/hub/client'
88
import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants'
99
import { DEVFRAME_EVENTS } from 'devframe/constants'
1010
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
@@ -637,12 +637,13 @@ export async function createDocksContext(
637637
// the captured session intent.
638638
// `switchEntry` then consumes the persisted iframe route when the view boots.
639639
const restoreAfterInitialization = async (): Promise<void> => {
640+
await waitUntilTrusted()
641+
640642
const restoreDockId = restoreIntent.selectedDockId
641643
if (!restoreIntent.open || restoreDockId == null)
642644
return
643645

644646
await Promise.all([
645-
waitUntilTrusted(),
646647
dockEntriesInitialSyncComplete,
647648
rendererManifestInitialSyncComplete,
648649
])
@@ -658,7 +659,15 @@ export async function createDocksContext(
658659
initialRestorePending.value = false
659660
await switchEntry(restoreDockId)
660661
}
661-
void restoreAfterInitialization()
662+
const reportPanelStateAfterInitialization = async (): Promise<void> => {
663+
await restoreAfterInitialization()
664+
watch(
665+
() => sessionStore.value.open,
666+
open => void reportDockPanelState(rpc, open).catch(() => {}),
667+
{ immediate: true },
668+
)
669+
}
670+
void reportPanelStateAfterInitialization()
662671

663672
docksContextByRpc.set(rpc, docksContext)
664673
return docksContext

packages/hub/src/client/__tests__/host.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
33
import type { DevframeDockEntry } from '../../types/docks'
44
import { createEventEmitter } from 'devframe/utils/events'
55
import { describe, expect, it, vi } from 'vitest'
6+
import { HUB_EVENTS } from '../../events'
67
import { getDevframeClientContext } from '../context'
78
import { createDevframeClientHost } from '../host'
89

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

6970
describe('createDevframeClientHost', () => {
71+
it('reports its initial panel state and later open-state assignments', async () => {
72+
expect.assertions(3)
73+
74+
const { rpc, calls } = createStubRpc()
75+
const host = await createDevframeClientHost({ rpc, clientType: 'embedded' })
76+
77+
expect(calls).toEqual([[HUB_EVENTS.rpc.docksPanelState, false]])
78+
79+
host.context.panel.session.open = true
80+
host.context.panel.session.open = true
81+
expect(calls).toEqual([
82+
[HUB_EVENTS.rpc.docksPanelState, false],
83+
[HUB_EVENTS.rpc.docksPanelState, true],
84+
])
85+
86+
host.context.panel.session.open = false
87+
expect(calls.at(-1)).toEqual([HUB_EVENTS.rpc.docksPanelState, false])
88+
host.dispose()
89+
})
90+
7091
it('publishes the global client context with the full surface', async () => {
7192
const { rpc } = createStubRpc()
7293
const host = await createDevframeClientHost({ rpc })

packages/hub/src/client/host.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { HUB_EVENTS } from '../events'
3232
import { getDevframeClientContext, setDevframeClientContext } from './context'
3333
import { attachFrameNavClient } from './frame-nav'
3434
import { createMessagesClient } from './messages'
35+
import { reportDockPanelState } from './panel-state'
3536
import { createDockRenderersContext } from './renderers'
3637

3738
const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks
@@ -154,7 +155,10 @@ export async function createDevframeClientHost(
154155
...options.categoryOrder,
155156
}
156157

157-
const panel = createPanelContext(clientType)
158+
const sendPanelState = (open: boolean): void => {
159+
void reportDockPanelState(rpc, open).catch(() => {})
160+
}
161+
const panel = createPanelContext(clientType, sendPanelState)
158162
const docks = createDocksContext()
159163
const commands = createCommandsContext()
160164
const renderers = createRenderersContext()
@@ -225,6 +229,7 @@ export async function createDevframeClientHost(
225229
)
226230
}
227231
setDevframeClientContext(context)
232+
sendPanelState(panel.session.open)
228233

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

550555
// ── shared helpers ─────────────────────────────────────────────────────────
551556

552-
function createPanelContext(clientType: DockClientType): DocksPanelContext {
557+
function createPanelContext(
558+
clientType: DockClientType,
559+
onOpenChange: (open: boolean) => void,
560+
): DocksPanelContext {
553561
const store: DocksPanelContext['store'] = {
554562
mode: 'edge',
555563
width: 480,
@@ -559,9 +567,17 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext {
559567
position: 'right',
560568
inactiveTimeout: 0,
561569
}
570+
let open = clientType === 'standalone'
562571
const session: DocksPanelContext['session'] = {
563-
// A standalone runtime owns the page, so its "panel" is always open.
564-
open: clientType === 'standalone',
572+
get open() {
573+
return open
574+
},
575+
set open(nextOpen) {
576+
if (nextOpen === open)
577+
return
578+
open = nextOpen
579+
onOpenChange(open)
580+
},
565581
selectedDockId: null,
566582
selectedDockRoute: null,
567583
}

packages/hub/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './frame-location'
77
export * from './frame-nav'
88
export * from './host'
99
export * from './messages'
10+
export * from './panel-state'
1011
export * from './remote'
1112
export * from './renderers'
1213
export * from 'devframe/client'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { DevframeRpcClient } from 'devframe/client'
2+
import { HUB_EVENTS } from '../events'
3+
4+
/** Report this RPC connection's current dock-panel state to the hub. */
5+
export async function reportDockPanelState(
6+
rpc: DevframeRpcClient,
7+
open: boolean,
8+
): Promise<void> {
9+
await rpc.call(HUB_EVENTS.rpc.docksPanelState, open)
10+
}

packages/hub/src/events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const HUB_EVENTS = {
2323
bus: {
2424
docksEntryUpdated: 'docks:entry:updated',
2525
docksActivate: 'docks:activate',
26+
docksPanelState: 'docks:panel:state',
2627
terminalsSessionUpdated: 'terminals:session:updated',
2728
messagesAdded: 'messages:added',
2829
messagesUpdated: 'messages:updated',
@@ -34,6 +35,7 @@ export const HUB_EVENTS = {
3435
/** Server RPC methods a connected client calls (client → server), `hub:` prefix. */
3536
rpc: {
3637
docksActivate: 'hub:docks:activate',
38+
docksPanelState: 'hub:docks:panel-state',
3739
commandsExecute: 'hub:commands:execute',
3840
messagesAdd: 'hub:messages:add',
3941
messagesUpdate: 'hub:messages:update',

packages/hub/src/node/__tests__/host-docks.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DevframeViewLauncher } from '../../types/docks'
1+
import type { DevframeDockPanelStateEvent, DevframeViewLauncher } from '../../types/docks'
22
import type { DevframeHubContext } from '../context'
33
import { mkdtempSync } from 'node:fs'
44
import { tmpdir } from 'node:os'
@@ -7,7 +7,9 @@ import { REMOTE_CONNECTION_KEY } from 'devframe/constants'
77
import { getInternalContext } from 'devframe/node/hub-internals'
88
import { describe, expect, it, vi } from 'vitest'
99
import { parseRemoteConnection } from '../../client/remote'
10+
import { HUB_EVENTS } from '../../events'
1011
import { DevframeDocksHost } from '../host-docks'
12+
import { disconnectDockPanelState, updateDockPanelState } from '../panel-state'
1113

1214
function createContext(): DevframeHubContext {
1315
const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-'))
@@ -221,6 +223,45 @@ describe('devframeDockHost activate', () => {
221223
})
222224
})
223225

226+
describe('devframeDockHost panel state', () => {
227+
it('emits the first report and changed values while suppressing duplicates', () => {
228+
expect.assertions(1)
229+
230+
const host = new DevframeDocksHost(createContext())
231+
const events: DevframeDockPanelStateEvent[] = []
232+
host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event))
233+
234+
updateDockPanelState(host, 11, false)
235+
updateDockPanelState(host, 11, false)
236+
updateDockPanelState(host, 11, true)
237+
238+
expect(events).toEqual([
239+
{ type: 'connected', sessionId: 11, open: false },
240+
{ type: 'changed', sessionId: 11, open: true },
241+
])
242+
})
243+
244+
it('tracks sessions independently and disconnects only reporting sessions', () => {
245+
expect.assertions(1)
246+
247+
const host = new DevframeDocksHost(createContext())
248+
const events: DevframeDockPanelStateEvent[] = []
249+
host.events.on(HUB_EVENTS.bus.docksPanelState, event => events.push(event))
250+
251+
updateDockPanelState(host, 11, true)
252+
updateDockPanelState(host, 12, false)
253+
disconnectDockPanelState(host, 99)
254+
disconnectDockPanelState(host, 11)
255+
disconnectDockPanelState(host, 11)
256+
257+
expect(events).toEqual([
258+
{ type: 'connected', sessionId: 11, open: true },
259+
{ type: 'connected', sessionId: 12, open: false },
260+
{ type: 'disconnected', sessionId: 11 },
261+
])
262+
})
263+
})
264+
224265
describe('devframeDockHost ~builtin category', () => {
225266
it('returns no docks until an integration registers one', () => {
226267
const host = new DevframeDocksHost(createContext())

packages/hub/src/node/__tests__/initiate.test.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
2+
import type { DevframeDockPanelStateEvent } from '../../types/docks'
23
import { mkdtempSync, writeFileSync } from 'node:fs'
34
import { createServer } from 'node:http'
45
import { tmpdir } from 'node:os'
56
import { join } from 'node:path'
67
import { createRpcClient } from 'devframe/rpc/client'
78
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
89
import { getPort } from 'get-port-please'
9-
import { describe, expect, it } from 'vitest'
10+
import { describe, expect, it, vi } from 'vitest'
1011
import { DOCK_RENDERERS_STATE_KEY } from '../../constants'
12+
import { HUB_EVENTS } from '../../events'
1113
import { DEVFRAMES_HUB_BASE, initHub } from '../initiate'
1214

1315
function makeDist(html: string): string {
@@ -38,10 +40,12 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition {
3840
}
3941

4042
function connectWsClient(url: string) {
41-
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
43+
const channel = createWsRpcChannel({ url })
44+
const client = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
4245
{} as DevframeRpcClientFunctions,
43-
{ channel: createWsRpcChannel({ url }) },
46+
{ channel },
4447
)
48+
return Object.assign(client, { close: channel.close })
4549
}
4650

4751
describe('initHub', () => {
@@ -152,6 +156,63 @@ describe('initHub', () => {
152156
}
153157
})
154158

159+
it('tracks panel state by RPC connection and emits disconnect separately from close', async () => {
160+
expect.assertions(9)
161+
162+
const host = '127.0.0.1'
163+
const port = await getPort({ port: 18215, host })
164+
const hub = initHub({
165+
base: DEVFRAMES_HUB_BASE,
166+
auth: false,
167+
host,
168+
ws: { port },
169+
devframes: [makeFrame('alpha')],
170+
})
171+
const clients: ReturnType<typeof connectWsClient>[] = []
172+
173+
try {
174+
await hub.ready
175+
const context = await hub.context
176+
const lifecycleEvents: DevframeDockPanelStateEvent[] = []
177+
context.docks.events.on(HUB_EVENTS.bus.docksPanelState, event => lifecycleEvents.push(event))
178+
179+
const firstClient = connectWsClient(`ws://${host}:${port}/__ws`)
180+
const secondClient = connectWsClient(`ws://${host}:${port}/__ws`)
181+
clients.push(firstClient, secondClient)
182+
183+
await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true)
184+
await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, true)
185+
await firstClient.$call(HUB_EVENTS.rpc.docksPanelState, false)
186+
await secondClient.$call(HUB_EVENTS.rpc.docksPanelState, false)
187+
188+
expect(lifecycleEvents).toHaveLength(3)
189+
expect(lifecycleEvents[0]).toMatchObject({ type: 'connected', open: true })
190+
expect(typeof lifecycleEvents[0]!.sessionId).toBe('number')
191+
expect(lifecycleEvents[1]).toEqual({ type: 'changed', sessionId: lifecycleEvents[0]!.sessionId, open: false })
192+
expect(lifecycleEvents[2]).toMatchObject({ type: 'connected', open: false })
193+
expect(lifecycleEvents[2]!.sessionId).not.toBe(lifecycleEvents[0]!.sessionId)
194+
195+
firstClient.close()
196+
await vi.waitFor(() => {
197+
if (lifecycleEvents.length !== 4)
198+
throw new Error('waiting for the first client to disconnect')
199+
})
200+
expect(lifecycleEvents[3]).toEqual({ type: 'disconnected', sessionId: lifecycleEvents[0]!.sessionId })
201+
202+
const reconnectedClient = connectWsClient(`ws://${host}:${port}/__ws`)
203+
clients.push(reconnectedClient)
204+
await reconnectedClient.$call(HUB_EVENTS.rpc.docksPanelState, true)
205+
206+
expect(lifecycleEvents[4]).toMatchObject({ type: 'connected', open: true })
207+
expect([lifecycleEvents[0]!.sessionId, lifecycleEvents[2]!.sessionId]).not.toContain(lifecycleEvents[4]!.sessionId)
208+
}
209+
finally {
210+
for (const client of clients)
211+
client.close()
212+
await hub.close()
213+
}
214+
})
215+
155216
it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => {
156217
const viewerDist = makeDist('<!doctype html><title>hub viewer</title>')
157218
const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-'))

0 commit comments

Comments
 (0)