Skip to content

Commit 42b2944

Browse files
committed
feat(hub): expose panel session lifecycle events
1 parent 00e2bdb commit 42b2944

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 { createEventEmitter } from 'devframe/utils/events'
56
import { createSharedState } from 'devframe/utils/shared-state'
67
import { describe, expect, it, vi } from 'vitest'
@@ -73,6 +74,38 @@ async function flushRestore(): Promise<void> {
7374
}
7475

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

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 { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
1010
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
@@ -636,12 +636,13 @@ export async function createDocksContext(
636636
// the captured session intent.
637637
// `switchEntry` then consumes the persisted iframe route when the view boots.
638638
const restoreAfterInitialization = async (): Promise<void> => {
639+
await waitUntilTrusted()
640+
639641
const restoreDockId = restoreIntent.selectedDockId
640642
if (!restoreIntent.open || restoreDockId == null)
641643
return
642644

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

662671
docksContextByRpc.set(rpc, docksContext)
663672
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,12 +1,14 @@
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'
11+
import { HUB_EVENTS } from '../../events'
1012
import { DEVFRAMES_HUB_BASE, initHub } from '../initiate'
1113

1214
function makeDist(html: string): string {
@@ -37,10 +39,12 @@ function makeFrame(id: string, distDir?: string): DevframeDefinition {
3739
}
3840

3941
function connectWsClient(url: string) {
40-
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
42+
const channel = createWsRpcChannel({ url })
43+
const client = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
4144
{} as DevframeRpcClientFunctions,
42-
{ channel: createWsRpcChannel({ url }) },
45+
{ channel },
4346
)
47+
return Object.assign(client, { close: channel.close })
4448
}
4549

4650
describe('initHub', () => {
@@ -134,6 +138,63 @@ describe('initHub', () => {
134138
}
135139
})
136140

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

0 commit comments

Comments
 (0)