From 1e4a2bb65a917e02c8e87eda32229ea409bbaba4 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 03:59:13 +0000 Subject: [PATCH 1/2] refactor(hub): unify internal event names and document event channels Rename the internal EventEmitter events on the subsystem hosts to the plural subsystem vocabulary already used across every wire name, so the bus lines up with its RPC/broadcast/shared-state counterparts: - dock:activate -> docks:activate - dock:entry:updated -> docks:entry:updated - terminal:session:updated -> terminals:session:updated - message:* -> messages:* - command:* -> commands:* Add a dedicated Hub Events Reference docs page tabling the three event channels (internal node bus, hub: server RPC, devframe: broadcasts and shared state) and their scope/flow. Wire names (hub:*, devframe:*) are public API and stay unchanged. --- docs/.vitepress/config.ts | 1 + docs/guide/events.md | 58 +++++++++++++++++++ docs/guide/hub.md | 2 +- .../hub/src/node/__tests__/host-docks.test.ts | 8 +-- .../src/node/__tests__/host-terminals.test.ts | 4 +- packages/hub/src/node/context.ts | 18 +++--- packages/hub/src/node/host-commands.ts | 6 +- packages/hub/src/node/host-docks.ts | 6 +- packages/hub/src/node/host-messages.ts | 8 +-- packages/hub/src/node/host-terminals.ts | 10 ++-- packages/hub/src/types/commands.ts | 4 +- packages/hub/src/types/docks.ts | 4 +- packages/hub/src/types/messages.ts | 8 +-- packages/hub/src/types/terminals.ts | 2 +- plugins/messages/src/spa/dev-host.ts | 8 +-- plugins/terminals/src/node/manager.ts | 4 +- plugins/terminals/test/_utils.ts | 8 +-- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 18 +++--- 18 files changed, 118 insertions(+), 59 deletions(-) create mode 100644 docs/guide/events.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c6c62964..7c3753df 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,7 @@ function guideGroups(prefix: string) { { text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` }, { text: 'Cross-Plugin Services', link: `${prefix}/guide/services` }, { text: 'Deep Linking', link: `${prefix}/guide/deep-linking` }, + { text: 'Events Reference', link: `${prefix}/guide/events` }, ], }, { diff --git a/docs/guide/events.md b/docs/guide/events.md new file mode 100644 index 00000000..1689027b --- /dev/null +++ b/docs/guide/events.md @@ -0,0 +1,58 @@ +--- +outline: deep +--- + +# Hub Events Reference + +The hub carries change notifications across three distinct channels. What separates them is **direction and reach**: an in-process event bus that never leaves the node process, server RPC methods a client calls, and server-pushed broadcasts and shared state a client reads. + +Two naming prefixes mark the wire surface: `hub:` for hub-layer server RPC (client → server actions), and `devframe:` for the client-facing devframe protocol (broadcasts, shared state, and streams pushed server → client). The internal event bus mirrors the same plural subsystem vocabulary (`docks`, `terminals`, `messages`, `commands`), so each internal event lines up with its wire counterpart — `docks:activate` fans out to `devframe:docks:activate`. + +## Internal node event bus + +Each subsystem host emits on `ctx..events`. These fire and are consumed **inside the same node process** — chiefly by `createHubContext`, which fans them out onto the wire. They never cross to the browser. + +| Event | Emitted by | Consumed by | Payload | +|---|---|---|---| +| `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` | +| `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` | +| `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 | + +The `docks:entry:updated` and `terminals:session:updated` middle nouns (`entry`, `session`) name the specific record type; the messages and commands subsystems imply their record in the subsystem name, so they carry the verb directly. + +## Server RPC methods — client → server + +A connected client (any mounted iframe or panel, on its own RPC client) calls these; the hub node handles them. Carry the `hub:` prefix. + +| Method | Signature | Purpose | +|---|---|---| +| `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](./deep-linking). | +| `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. | +| `hub:messages:remove` | `(id) => void` | Remove a message by id. | +| `hub:messages:clear` | `() => void` | Remove every message. | +| `hub:terminals:write` | `(id, data) => void` | Send input to an interactive PTY session. | +| `hub:terminals:resize` | `(id, cols, rows) => void` | Resize an interactive PTY session. | +| `hub:terminals:terminate` | `(id) => void` | Kill a session's process, keeping it registered. | +| `hub:terminals:restart` | `(id) => void` | Re-run a session's command in place. | +| `hub:terminals:remove` | `(id) => void` | Kill a session's process and drop it from the registry. | + +## Broadcasts & shared state — server → client + +The server pushes these; a hub-aware client reads or subscribes. Carry the `devframe:` prefix. A UI subscribes to broadcasts via `rpc.client.register(...)`; the [client host](./client-context) registers the `devframe:docks:activate` handler for you. + +| Name | Kind | Carries | +|---|---|---| +| `devframe:docks:activate` | broadcast | Live "switch active dock" request — the client host calls its local `switchEntry`. | +| `devframe:terminals:updated` | broadcast | Terminal sessions changed; re-read terminal state. | +| `devframe:messages:updated` | broadcast | Message list changed; re-read message state. | +| `devframe:docks` | shared state | Projected dock entry list (`DevframeDockEntry[]`). | +| `devframe:docks:active` | shared state | Most recent `DevframeDockActivation`, so a dock that mounts in response still converges on it. | +| `devframe:commands` | shared state | Serializable command list, handlers stripped (`DevframeServerCommandEntry[]`). | +| `devframe:user-settings` | shared state | Persisted per-workspace hub settings (`DevframeDocksUserSettings`). | +| `devframe:terminals` | streaming channel | Live terminal output stream, keyed by session id. | + +The [`devframe:docks:active`](./shared-state) mirror pairs with the `devframe:docks:activate` broadcast: the broadcast reaches docks already on screen, while the mirror lets a dock that mounts *because* of the switch converge on the same request instead of missing it. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index dc521284..9f04247a 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -275,7 +275,7 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys | `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. | | `hub:docks:activate` RPC | `({ dockId, params? }) => void` | Switch the active dock from any client. | -Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you. +Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you. The [Events Reference](./events) tables every channel across all four subsystems. ## Running plugin code in the host page diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 392b6b6b..81313b42 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -85,7 +85,7 @@ describe('devframeDockHost grouping', () => { it('registers a group entry: stored, projected, and emitted', () => { const host = new DevframeDocksHost(createContext()) const emitted: string[] = [] - host.events.on('dock:entry:updated', entry => emitted.push(entry.id)) + host.events.on('docks:entry:updated', entry => emitted.push(entry.id)) host.register({ type: 'group', @@ -192,12 +192,12 @@ describe('devframeDockHost grouping', () => { }) describe('devframeDockHost activate', () => { - it('emits a dock:activate event carrying the id and params', () => { + it('emits a docks:activate event carrying the id and params', () => { const host = new DevframeDocksHost(createContext()) host.register({ type: 'iframe', id: 'terminals', title: 'Terminals', icon: 'ph:terminal-window-duotone', url: '/__terminals/' }) const activations: Array<{ dockId: string, params?: Record }> = [] - host.events.on('dock:activate', a => activations.push(a)) + host.events.on('docks:activate', a => activations.push(a)) host.activate('terminals', { sessionId: 'sess-1' }) expect(activations).toEqual([{ dockId: 'terminals', params: { sessionId: 'sess-1' } }]) @@ -208,7 +208,7 @@ describe('devframeDockHost activate', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const activations: string[] = [] - host.events.on('dock:activate', a => activations.push(a.dockId)) + host.events.on('docks:activate', a => activations.push(a.dockId)) host.activate('nope') expect(activations).toEqual(['nope']) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index 26049d58..b3b2e056 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -261,7 +261,7 @@ 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)) + host.events.on('terminals:session:updated', s => updates.push(s.status)) const session = await host.startChildProcess({ command: process.execPath, @@ -441,7 +441,7 @@ describe('devframeTerminalHost PTY status lifecycle', () => { itPty('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)) + host.events.on('terminals:session:updated', s => updates.push(s.status)) const session = await host.startPtySession({ command: NODE, diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 71d7c182..c0539638 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -155,7 +155,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis const refreshDocks = debounce(() => { docksSharedState.mutate(() => docks.values()) }, debounceMs) - docks.events.on('dock:entry:updated', refreshDocks) + docks.events.on('docks:entry:updated', refreshDocks) // A remote iframe dock registered before the WS transport finishes binding // (the common case: `initHub` installs devframes — and their docks — before // resolving an async side-car/shared-server port) gets projected without a @@ -174,7 +174,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis 'devframe:docks:active', { initialValue: { activation: null } }, ) - docks.events.on('dock:activate', (activation) => { + docks.events.on('docks:activate', (activation) => { activeDockSharedState.mutate((state) => { state.activation = activation }) @@ -191,7 +191,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis }) docksSharedState.mutate(() => docks.values()) }, debounceMs) - terminals.events.on('terminal:session:updated', broadcastTerminals) + terminals.events.on('terminals:session:updated', broadcastTerminals) const broadcastMessages = debounce(() => { context.rpc.broadcast({ @@ -200,17 +200,17 @@ export async function createHubContext(options: CreateHubContextOptions): Promis }) docksSharedState.mutate(() => docks.values()) }, debounceMs) - messages.events.on('message:added', broadcastMessages) - messages.events.on('message:updated', broadcastMessages) - messages.events.on('message:removed', broadcastMessages) - messages.events.on('message:cleared', broadcastMessages) + messages.events.on('messages:added', broadcastMessages) + messages.events.on('messages:updated', broadcastMessages) + messages.events.on('messages:removed', broadcastMessages) + messages.events.on('messages:cleared', broadcastMessages) const commandsSharedState = await context.rpc.sharedState.get('devframe:commands', { initialValue: [] }) const syncCommands = debounce(() => { commandsSharedState.mutate(() => commands.list()) }, debounceMs) - commands.events.on('command:registered', syncCommands) - commands.events.on('command:unregistered', syncCommands) + commands.events.on('commands:registered', syncCommands) + commands.events.on('commands:unregistered', syncCommands) commandsSharedState.mutate(() => commands.list()) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index 82d6ceeb..a1113536 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -76,7 +76,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { validateCommandIds(this.commands, command) this.validateAgentExposure(command) this.commands.set(command.id, command) - this.events.emit('command:registered', this.toSerializable(command)) + this.events.emit('commands:registered', this.toSerializable(command)) this.agentProvider?.notifyChanged() return { @@ -97,7 +97,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { validateCommandIds(this.commands, next, existing.id) this.validateAgentExposure(next) Object.assign(existing, patch) - this.events.emit('command:registered', this.toSerializable(existing)) + this.events.emit('commands:registered', this.toSerializable(existing)) this.agentProvider?.notifyChanged() }, unregister: () => this.unregister(command.id), @@ -107,7 +107,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { unregister(id: string): boolean { const deleted = this.commands.delete(id) if (deleted) { - this.events.emit('command:unregistered', id) + this.events.emit('commands:unregistered', id) this.agentProvider?.notifyChanged() } return deleted diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 4f1769a6..41418da8 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -91,7 +91,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { this.validateGroupMembership(view) this.prepareRemoteRegistration(view) this.views.set(view.id, view) - this.events.emit('dock:entry:updated', view) + this.events.emit('docks:entry:updated', view) return { update: (patch) => { @@ -115,7 +115,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { this.validateGroupMembership(view) this.prepareRemoteRegistration(view) this.views.set(view.id, view) - this.events.emit('dock:entry:updated', view) + this.events.emit('docks:entry:updated', view) } activate(dockId: string, params?: Record): void { @@ -125,7 +125,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { // rather than fatal. if (!this.views.has(dockId)) diagnostics.DF8107({ id: dockId }) - this.events.emit('dock:activate', { dockId, params }) + this.events.emit('docks:activate', { dockId, params }) } private validateGroupMembership(view: DevframeDockUserEntry): void { diff --git a/packages/hub/src/node/host-messages.ts b/packages/hub/src/node/host-messages.ts index 519018bb..75d87ea7 100644 --- a/packages/hub/src/node/host-messages.ts +++ b/packages/hub/src/node/host-messages.ts @@ -69,7 +69,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.set(entry.id, entry) this.lastModified.set(entry.id, this._tick()) - this.events.emit('message:added', entry) + this.events.emit('messages:added', entry) if (entry.autoDelete) { this._autoDeleteTimers.set(entry.id, setTimeout(() => { @@ -95,7 +95,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.set(id, updated) this.lastModified.set(id, this._tick()) - this.events.emit('message:updated', updated) + this.events.emit('messages:updated', updated) // Reset autoDelete timer if changed if (patch.autoDelete !== undefined) { @@ -123,7 +123,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.delete(id) this.lastModified.delete(id) this._recordRemoval(id, this._tick()) - this.events.emit('message:removed', id) + this.events.emit('messages:removed', id) } info(message: string, extra?: DevframeMessageShortcutInput): Promise { @@ -155,7 +155,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this._recordRemoval(id, tick) this.entries.clear() this.lastModified.clear() - this.events.emit('message:cleared') + this.events.emit('messages:cleared') } listSince(since?: number | null): DevframeMessagesListDelta { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index abd698d4..8b899d10 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -71,7 +71,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { } this.sessions.set(session.id, session) this.bindStream(session) - this.events.emit('terminal:session:updated', session) + this.events.emit('terminals:session:updated', session) return session } @@ -83,13 +83,13 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { Object.assign(session, patch) this.sessions.set(patch.id, session) this.bindStream(session) - this.events.emit('terminal:session:updated', session) + this.events.emit('terminals:session:updated', session) } remove(session: DevframeTerminalSession): void { this._boundStreams.get(session.id)?.dispose() this.sessions.delete(session.id) - this.events.emit('terminal:session:updated', session) + this.events.emit('terminals:session:updated', session) this._boundStreams.delete(session.id) } @@ -185,7 +185,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (session.status === next) return session.status = next - this.events.emit('terminal:session:updated', session) + this.events.emit('terminals:session:updated', session) } const closeStream = () => { @@ -376,7 +376,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (session.status === next) return session.status = next - this.events.emit('terminal:session:updated', session) + this.events.emit('terminals:session:updated', session) } const closeStream = () => { diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts index 0f9a79c1..ab4b4ee7 100644 --- a/packages/hub/src/types/commands.ts +++ b/packages/hub/src/types/commands.ts @@ -135,8 +135,8 @@ export interface DevframeCommandHandle { } export interface DevframeCommandsHostEvents { - 'command:registered': (command: DevframeServerCommandEntry) => void - 'command:unregistered': (id: string) => void + 'commands:registered': (command: DevframeServerCommandEntry) => void + 'commands:unregistered': (id: string) => void } export interface DevframeCommandsHost { diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 65d4568f..14163e6d 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -3,8 +3,8 @@ import type { ConnectionMeta, EventEmitter } from 'devframe/types' export interface DevframeDocksHost { readonly views: Map readonly events: EventEmitter<{ - 'dock:entry:updated': (entry: DevframeDockUserEntry) => void - 'dock:activate': (activation: DevframeDockActivation) => void + 'docks:entry:updated': (entry: DevframeDockUserEntry) => void + 'docks:activate': (activation: DevframeDockActivation) => void }> register: (entry: T, force?: boolean) => { diff --git a/packages/hub/src/types/messages.ts b/packages/hub/src/types/messages.ts index d64c3dcb..28603377 100644 --- a/packages/hub/src/types/messages.ts +++ b/packages/hub/src/types/messages.ts @@ -206,10 +206,10 @@ export interface DevframeMessagesListDelta { export interface DevframeMessagesHost extends DevframeMessagesLevelShortcuts { readonly entries: Map readonly events: EventEmitter<{ - 'message:added': (entry: DevframeMessageEntry) => void - 'message:updated': (entry: DevframeMessageEntry) => void - 'message:removed': (id: string) => void - 'message:cleared': () => void + 'messages:added': (entry: DevframeMessageEntry) => void + 'messages:updated': (entry: DevframeMessageEntry) => void + 'messages:removed': (id: string) => void + 'messages:cleared': () => void }> /** diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 3243485b..939634a9 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -5,7 +5,7 @@ import type { DevframeDockEntryIcon } from './docks' export interface DevframeTerminalsHost { readonly sessions: Map readonly events: EventEmitter<{ - 'terminal:session:updated': (session: DevframeTerminalSession) => void + 'terminals:session:updated': (session: DevframeTerminalSession) => void }> register: (session: DevframeTerminalSession) => DevframeTerminalSession diff --git a/plugins/messages/src/spa/dev-host.ts b/plugins/messages/src/spa/dev-host.ts index 570c11cf..6baf6658 100644 --- a/plugins/messages/src/spa/dev-host.ts +++ b/plugins/messages/src/spa/dev-host.ts @@ -31,10 +31,10 @@ function wireBroadcast(ctx: DevframeNodeContext, messages: DevframeMessagesHostT const broadcast = (): void => { ctx.rpc.broadcast({ method: MESSAGES_UPDATED_EVENT, args: [] }) } - messages.events.on('message:added', broadcast) - messages.events.on('message:updated', broadcast) - messages.events.on('message:removed', broadcast) - messages.events.on('message:cleared', broadcast) + messages.events.on('messages:added', broadcast) + messages.events.on('messages:updated', broadcast) + messages.events.on('messages:removed', broadcast) + messages.events.on('messages:cleared', broadcast) } function seedDemoMessages(messages: DevframeMessagesHostType): void { diff --git a/plugins/terminals/src/node/manager.ts b/plugins/terminals/src/node/manager.ts index 03cc00db..a4bbbd05 100644 --- a/plugins/terminals/src/node/manager.ts +++ b/plugins/terminals/src/node/manager.ts @@ -83,7 +83,7 @@ interface HubTerminalsBridge { update: (session: HubTerminalEntry) => void remove?: (session: { id: string }) => void events?: { - on: (event: 'terminal:session:updated', cb: (session: HubTerminalEntry) => void) => void + on: (event: 'terminals:session:updated', cb: (session: HubTerminalEntry) => void) => void } } @@ -176,7 +176,7 @@ export class TerminalManager { // aggregated sessions appear/update/disappear in this plugin's UI. Guarded // to foreign ids so mirroring our *own* sessions into the hub can't loop. const hub = this.hubTerminals() - hub?.events?.on('terminal:session:updated', (session) => { + hub?.events?.on('terminals:session:updated', (session) => { if (!this.sessions.has(session.id)) this.refreshSessionsState() }) diff --git a/plugins/terminals/test/_utils.ts b/plugins/terminals/test/_utils.ts index 6a69ae75..e1009012 100644 --- a/plugins/terminals/test/_utils.ts +++ b/plugins/terminals/test/_utils.ts @@ -39,7 +39,7 @@ export interface FakeHubTerminals { /** * Minimal stand-in for the hub's `ctx.terminals` aggregation host — a sessions - * map plus a `terminal:session:updated` emitter — so tests can exercise how the + * map plus a `terminals:session:updated` emitter — so tests can exercise how the * terminals plugin surfaces sessions contributed by *other* devframes. */ export function createFakeHubTerminals(): FakeHubTerminals { @@ -50,19 +50,19 @@ export function createFakeHubTerminals(): FakeHubTerminals { events, register(entry) { sessions.set(entry.id, entry) - events.emit('terminal:session:updated', entry) + events.emit('terminals:session:updated', entry) return entry }, update(patch) { const cur = sessions.get(patch.id) if (cur) Object.assign(cur, patch) - events.emit('terminal:session:updated', sessions.get(patch.id) ?? patch) + events.emit('terminals:session:updated', sessions.get(patch.id) ?? patch) }, remove(entry) { const cur = sessions.get(entry.id) sessions.delete(entry.id) - events.emit('terminal:session:updated', cur ?? entry) + events.emit('terminals:session:updated', cur ?? entry) }, } } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 9aff9c44..76e049e8 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -74,8 +74,8 @@ export interface DevframeCommandsHost { list: () => DevframeServerCommandEntry[]; } export interface DevframeCommandsHostEvents { - 'command:registered': (_: DevframeServerCommandEntry) => void; - 'command:unregistered': (_: string) => void; + 'commands:registered': (_: DevframeServerCommandEntry) => void; + 'commands:unregistered': (_: string) => void; } export interface DevframeDockActivation { dockId: string; @@ -107,8 +107,8 @@ export interface DevframeDocksActiveState { export interface DevframeDocksHost { readonly views: Map; readonly events: EventEmitter<{ - 'dock:entry:updated': (entry: DevframeDockUserEntry) => void; - 'dock:activate': (activation: DevframeDockActivation) => void; + 'docks:entry:updated': (entry: DevframeDockUserEntry) => void; + 'docks:activate': (activation: DevframeDockActivation) => void; }>; register: (_: T, _?: boolean) => { update: (_: Partial) => void; @@ -197,10 +197,10 @@ export interface DevframeMessagesClient extends DevframeMessagesLevelShortcuts { export interface DevframeMessagesHost extends DevframeMessagesLevelShortcuts { readonly entries: Map; readonly events: EventEmitter<{ - 'message:added': (entry: DevframeMessageEntry) => void; - 'message:updated': (entry: DevframeMessageEntry) => void; - 'message:removed': (id: string) => void; - 'message:cleared': () => void; + 'messages:added': (entry: DevframeMessageEntry) => void; + 'messages:updated': (entry: DevframeMessageEntry) => void; + 'messages:removed': (id: string) => void; + 'messages:cleared': () => void; }>; add: (_: DevframeMessageEntryInput) => Promise; update: (_: string, _: Partial) => Promise; @@ -264,7 +264,7 @@ export interface DevframeTerminalSessionBase { export interface DevframeTerminalsHost { readonly sessions: Map; readonly events: EventEmitter<{ - 'terminal:session:updated': (session: DevframeTerminalSession) => void; + 'terminals:session:updated': (session: DevframeTerminalSession) => void; }>; register: (_: DevframeTerminalSession) => DevframeTerminalSession; update: (_: DevframeTerminalSession) => void; From 591cd687e4c29fe2f5f873235f94183fc4b3dc09 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 05:16:53 +0000 Subject: [PATCH 2/2] refactor: centralize event names in DEVFRAME_EVENTS / HUB_EVENTS maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce one source-of-truth event map per package and reference it from every call site, so event/broadcast/shared-state/channel names stop living as scattered string literals: - packages/devframe/src/events.ts (DEVFRAME_EVENTS) — agent host bus events, client connection events, and server->client broadcasts; re-exported from devframe/constants. - packages/hub/src/events.ts (HUB_EVENTS) — the docks/terminals/messages/ commands bus events, hub: RPC methods, devframe: broadcasts, shared-state keys, and channels; re-exported from @devframes/hub/constants. Call sites across both packages (plus hub-ui and the messages dev harness) now reference the maps instead of literals. The unavoidable type-position keys (EventEmitter<...> maps, RPC augmentation interfaces) mirror the maps. Document the core devframe channels in the Events Reference and note that the two maps back the page; add an AGENTS.md rule requiring the maps and events.md to move together and forbidding magic event names. Public constant types are preserved (DOCK_RENDERERS_STATE_KEY, FRAME_NAV_CHANNEL, DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE stay `string`); the snapshot change is purely the additive HUB_EVENTS / DEVFRAME_EVENTS. --- AGENTS.md | 1 + docs/guide/events.md | 56 +++++++++++++-- .../devframe/src/adapters/mcp/build-server.ts | 3 +- packages/devframe/src/client/rpc-live.ts | 27 +++---- .../devframe/src/client/rpc-shared-state.ts | 7 +- packages/devframe/src/client/rpc-streaming.ts | 11 +-- packages/devframe/src/constants.ts | 6 +- packages/devframe/src/events.ts | 64 +++++++++++++++++ packages/devframe/src/node/auth/revoke.ts | 3 +- packages/devframe/src/node/host-agent.ts | 25 +++---- .../devframe/src/node/rpc-shared-state.ts | 5 +- packages/devframe/src/node/rpc-streaming.ts | 11 +-- packages/devframe/src/utils/serve-static.ts | 4 +- .../client/components/views/ViewLauncher.vue | 3 +- packages/hub-ui/src/client/state/context.ts | 4 +- packages/hub/src/client/frame-nav.ts | 3 +- packages/hub/src/client/host.ts | 9 +-- packages/hub/src/constants.ts | 4 +- packages/hub/src/events.ts | 70 +++++++++++++++++++ packages/hub/src/node/context.ts | 31 ++++---- packages/hub/src/node/host-commands.ts | 7 +- packages/hub/src/node/host-docks.ts | 9 +-- packages/hub/src/node/host-messages.ts | 9 +-- packages/hub/src/node/host-terminals.ts | 13 ++-- packages/hub/src/node/rpc-builtins.ts | 23 +++--- plugins/messages/src/spa/dev-host.ts | 9 +-- .../@devframes/hub/constants.snapshot.d.ts | 44 ++++++++++++ .../@devframes/hub/constants.snapshot.js | 1 + .../tsnapi/devframe/constants.snapshot.d.ts | 26 +++++++ .../tsnapi/devframe/constants.snapshot.js | 1 + 30 files changed, 383 insertions(+), 106 deletions(-) create mode 100644 packages/devframe/src/events.ts create mode 100644 packages/hub/src/events.ts diff --git a/AGENTS.md b/AGENTS.md index b8915526..02e73dc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ Ahead-of-time build artifacts that live under `src/` - the shadow-root styleshee ## Conventions - RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin::` (matching the plugin's `@devframes/plugin-` package name). +- **No magic event names — use the centralized event maps.** Every event, broadcast, shared-state key, and channel name lives in one of two source-of-truth maps: `DEVFRAME_EVENTS` (`packages/devframe/src/events.ts`, re-exported from `devframe/constants`) for the core runtime, and `HUB_EVENTS` (`packages/hub/src/events.ts`, re-exported from `@devframes/hub/constants`) for the hub. Reference `DEVFRAME_EVENTS.*` / `HUB_EVENTS.*` at call sites (`.events.emit`/`.on`, `rpc.broadcast({ method })`, `sharedState.get(key)`, `defineHubRpcFunction({ name })`, `rpc.call`) instead of re-typing a string literal. The two maps and the [`docs/guide/events.md`](docs/guide/events.md) Events Reference are kept in lockstep: adding, renaming, or removing a name means editing the map **and** that page in the same change — every name in the maps appears in the tables, and vice versa. The only literals left are unavoidable type-position keys (the `EventEmitter<…>` maps in `types/*` and the `DevframeRpcClientFunctions`/`DevframeRpcServerFunctions` augmentations), which mirror the maps; a package that deliberately avoids a hub dependency (e.g. `@devframes/plugin-terminals`, which models the hub bridge structurally) keeps a local literal rather than importing `HUB_EVENTS`. - **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency - no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal - not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise - no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations - recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations). - Shared state via `devframe/utils/shared-state`; keep values serializable. - Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`. diff --git a/docs/guide/events.md b/docs/guide/events.md index 1689027b..d4b3f024 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -2,13 +2,17 @@ outline: deep --- -# Hub Events Reference +# Events Reference -The hub carries change notifications across three distinct channels. What separates them is **direction and reach**: an in-process event bus that never leaves the node process, server RPC methods a client calls, and server-pushed broadcasts and shared state a client reads. +Devframe carries change notifications across a few distinct channels. What separates them is **direction and reach**: an in-process event bus that never leaves the node process, server RPC methods a client calls, and server-pushed broadcasts and shared state a client reads. Two naming prefixes mark the wire surface: `hub:` for hub-layer server RPC (client → server actions), and `devframe:` for the client-facing devframe protocol (broadcasts, shared state, and streams pushed server → client). The internal event bus mirrors the same plural subsystem vocabulary (`docks`, `terminals`, `messages`, `commands`), so each internal event lines up with its wire counterpart — `docks:activate` fans out to `devframe:docks:activate`. -## Internal node event bus +Every name on this page has one home in code: the [`HUB_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/hub/src/events.ts) map (`@devframes/hub/constants`) backs the hub tables, and the [`DEVFRAME_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/events.ts) map (`devframe/constants`) backs the core tables. Call sites reference `HUB_EVENTS.*` / `DEVFRAME_EVENTS.*` rather than re-typing a literal, and this page and those maps move together — changing one without the other is a bug. + +## Hub events + +### Internal node event bus Each subsystem host emits on `ctx..events`. These fire and are consumed **inside the same node process** — chiefly by `createHubContext`, which fans them out onto the wire. They never cross to the browser. @@ -22,7 +26,7 @@ Each subsystem host emits on `ctx..events`. These fire and are consum The `docks:entry:updated` and `terminals:session:updated` middle nouns (`entry`, `session`) name the specific record type; the messages and commands subsystems imply their record in the subsystem name, so they carry the verb directly. -## Server RPC methods — client → server +### Server RPC methods — client → server A connected client (any mounted iframe or panel, on its own RPC client) calls these; the hub node handles them. Carry the `hub:` prefix. @@ -40,7 +44,7 @@ A connected client (any mounted iframe or panel, on its own RPC client) calls th | `hub:terminals:restart` | `(id) => void` | Re-run a session's command in place. | | `hub:terminals:remove` | `(id) => void` | Kill a session's process and drop it from the registry. | -## Broadcasts & shared state — server → client +### Broadcasts & shared state — server → client The server pushes these; a hub-aware client reads or subscribes. Carry the `devframe:` prefix. A UI subscribes to broadcasts via `rpc.client.register(...)`; the [client host](./client-context) registers the `devframe:docks:activate` handler for you. @@ -56,3 +60,45 @@ The server pushes these; a hub-aware client reads or subscribes. Carry the `devf | `devframe:terminals` | streaming channel | Live terminal output stream, keyed by session id. | The [`devframe:docks:active`](./shared-state) mirror pairs with the `devframe:docks:activate` broadcast: the broadcast reaches docks already on screen, while the mirror lets a dock that mounts *because* of the switch converge on the same request instead of missing it. + +## Core devframe events + +The core `devframe` runtime (below the hub) carries its own notification channels — the agent host's change events, the client connection lifecycle, and the server-pushed broadcasts that power shared state and streaming. These are backed by `DEVFRAME_EVENTS` (`devframe/constants`). + +This map covers notifications only. The request/response RPC endpoints of the shared-state, streaming, and auth-handshake protocols (`devframe:rpc:server-state:*`, `devframe:streaming:subscribe`, `anonymous:devframe:auth`, …) are defined at their handlers and typed in `types/rpc-augments.ts` — they aren't events. + +### Node host bus + +Emitted on `ctx.agent.events` as the agent-exposed tool/resource surface changes; protocol adapters (e.g. the MCP server) subscribe to re-publish their manifest. + +| Event | Emitted by | Payload | +|---|---|---| +| `agent:manifest:changed` | any tool/resource/provider change | — | +| `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id | +| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id | + +### Client connection events + +Emitted on the RPC client's `rpc.events` emitter (`RpcClientEvents`) for a UI to track connection lifecycle and surface errors. + +| Event | Carries | +|---|---| +| `rpc:is-trusted:updated` | Trust gate flipped (`boolean`). | +| `rpc:error` | An RPC call rejected (`error`, `method`). | +| `connection:status` | Connection status changed (`status`, `previous`). | +| `connection:error` | A connection-level error (WebSocket errored, or trust refused). | + +### Broadcasts — server → client + +Pushed from the server to subscribed clients over the `devframe:` protocol. Wired by the framework's own hosts; not registered manually. + +| Name | Carries | +|---|---| +| `devframe:auth:revoked` | This connection's bearer token was revoked; the client drops to untrusted. | +| `devframe:rpc:client-state:updated` | Full shared-state snapshot for a key. | +| `devframe:rpc:client-state:patch` | Incremental shared-state patch for a key. | +| `devframe:streaming:chunk` | A streaming chunk for a subscribed channel/id. | +| `devframe:streaming:end` | A streaming terminator (optionally an error). | +| `devframe:streaming:upload-cancel` | Server-side cancel of an in-flight upload. | + +Plus one `postMessage` channel, `devframe:remote-assets-error`, that the remote-assets fallback page posts to `window.parent` so an embedding viewer can replace the bare 502 page with its own UI. diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 81548a2c..f0a3d04f 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -8,6 +8,7 @@ import { Server } from '@modelcontextprotocol/server' import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' +import { DEVFRAME_EVENTS } from '../../events' import { diagnostics } from '../../node/diagnostics' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' @@ -70,7 +71,7 @@ export function buildMcpServerFromContext( const notify = (method: string): void => { server.notification({ method }).catch(() => { /* ignore transport errors */ }) } - const offManifest = ctx.agent.events.on('agent:manifest:changed', () => { + const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { notify('notifications/tools/list_changed') notify('notifications/resources/list_changed') }) diff --git a/packages/devframe/src/client/rpc-live.ts b/packages/devframe/src/client/rpc-live.ts index c3546ad3..74968292 100644 --- a/packages/devframe/src/client/rpc-live.ts +++ b/packages/devframe/src/client/rpc-live.ts @@ -3,6 +3,7 @@ import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunct import type { DevframeConnectionStatus } from './connection' import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc' import { createRpcClient } from 'devframe/rpc/client' +import { DEVFRAME_EVENTS } from '../events' import { promiseWithResolver } from '../utils/promise' import { DevframeConnectionError } from './connection' @@ -65,7 +66,7 @@ export function createLiveRpcClientMode( return const previous = status status = next - events.emit('connection:status', next, previous) + events.emit(DEVFRAME_EVENTS.client.connectionStatus, next, previous) } // Pending calls we can settle proactively — a connection that drops (or a @@ -99,7 +100,7 @@ export function createLiveRpcClientMode( if (settled) return finish() - events.emit('rpc:error', error, method) + events.emit(DEVFRAME_EVENTS.client.error, error, method) reject(error) }, } @@ -127,7 +128,7 @@ export function createLiveRpcClientMode( return finish() const err = error instanceof Error ? error : new Error(String(error)) - events.emit('rpc:error', err, method) + events.emit(DEVFRAME_EVENTS.client.error, err, method) reject(err) }, ) @@ -148,7 +149,7 @@ export function createLiveRpcClientMode( definitions, onError(error) { setStatus('error', error) - events.emit('connection:error', error) + events.emit(DEVFRAME_EVENTS.client.connectionError, error) rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error })) }, onDisconnected() { @@ -169,15 +170,15 @@ export function createLiveRpcClientMode( // Handle server-initiated auth revocation clientRpc.register({ - name: 'devframe:auth:revoked', + name: DEVFRAME_EVENTS.broadcast.authRevoked, type: 'event', handler: () => { isTrusted = false const authError = new DevframeConnectionError('auth', '[devframe] The devframe server revoked this client\'s trust') setStatus('unauthorized', authError) - events.emit('connection:error', authError) + events.emit(DEVFRAME_EVENTS.client.connectionError, authError) rejectAllPending(authError) - events.emit('rpc:is-trusted:updated', false) + events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false) }, }) @@ -209,9 +210,9 @@ export function createLiveRpcClientMode( // so it never lands here. const authError = new DevframeConnectionError('auth', '[devframe] The devframe server refused this client\'s credentials') setStatus('unauthorized', authError) - events.emit('connection:error', authError) + events.emit(DEVFRAME_EVENTS.client.connectionError, authError) } - events.emit('rpc:is-trusted:updated', isTrusted) + events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, isTrusted) return result.isTrusted } @@ -228,7 +229,7 @@ export function createLiveRpcClientMode( isTrusted = true trustedPromise.resolve(true) setStatus('connected') - events.emit('rpc:is-trusted:updated', true) + events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) } return token } @@ -284,7 +285,7 @@ export function createLiveRpcClientMode( const method = String(args[0]) const failFast = terminalError() if (failFast) { - events.emit('rpc:error', failFast, method) + events.emit(DEVFRAME_EVENTS.client.error, failFast, method) return Promise.reject(failFast) } return guardCall( @@ -300,7 +301,7 @@ export function createLiveRpcClientMode( // to send, so surface the failure and drop it instead of queuing forever. const failFast = terminalError() if (failFast) { - events.emit('rpc:error', failFast, String(args[0])) + events.emit(DEVFRAME_EVENTS.client.error, failFast, String(args[0])) return } return serverRpc.$callEvent( @@ -312,7 +313,7 @@ export function createLiveRpcClientMode( const method = String(args[0]) const failFast = terminalError() if (failFast) { - events.emit('rpc:error', failFast, method) + events.emit(DEVFRAME_EVENTS.client.error, failFast, method) return Promise.reject(failFast) } return guardCall( diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index 3e3ffb87..ae7c6b28 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -2,6 +2,7 @@ import type { RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/type import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import type { DevframeRpcClient } from './rpc' import { createSharedState } from 'devframe/utils/shared-state' +import { DEVFRAME_EVENTS } from '../events' export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost { const sharedState = new Map>() @@ -20,7 +21,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare } rpc.client.register({ - name: 'devframe:rpc:client-state:updated', + name: DEVFRAME_EVENTS.broadcast.clientStateUpdated, type: 'event', handler: (key: string, fullState: any, syncId: string) => { const state = sharedState.get(key) @@ -31,7 +32,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare }) rpc.client.register({ - name: 'devframe:rpc:client-state:patch', + name: DEVFRAME_EVENTS.broadcast.clientStatePatch, type: 'event', handler: (key: string, patches: SharedStatePatch[], syncId: string) => { const state = sharedState.get(key) @@ -124,7 +125,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare if (!rpc.isTrusted) { resolve(state) let initialized = false - rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (isTrusted) => { if (isTrusted && !initialized) { initialized = true initSharedState() diff --git a/packages/devframe/src/client/rpc-streaming.ts b/packages/devframe/src/client/rpc-streaming.ts index 72646589..3f046a6f 100644 --- a/packages/devframe/src/client/rpc-streaming.ts +++ b/packages/devframe/src/client/rpc-streaming.ts @@ -1,6 +1,7 @@ import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' import type { DevframeRpcClient } from './rpc' import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' +import { DEVFRAME_EVENTS } from '../events' const STREAM_KEY_SEPARATOR = '\x1F' @@ -46,7 +47,7 @@ export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreami const uploads = new Map>() rpc.client.register({ - name: 'devframe:streaming:chunk', + name: DEVFRAME_EVENTS.broadcast.streamingChunk, type: 'event', handler(channel: string, id: string, seq: number, chunk: any) { const reader = readers.get(streamKey(channel, id)) @@ -55,7 +56,7 @@ export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreami }) rpc.client.register({ - name: 'devframe:streaming:end', + name: DEVFRAME_EVENTS.broadcast.streamingEnd, type: 'event', handler(channel: string, id: string, error?: StreamErrorPayload) { const key = streamKey(channel, id) @@ -68,7 +69,7 @@ export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreami }) rpc.client.register({ - name: 'devframe:streaming:upload-cancel', + name: DEVFRAME_EVENTS.broadcast.streamingUploadCancel, type: 'event', handler(channel: string, id: string) { const key = streamKey(channel, id) @@ -87,7 +88,7 @@ export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreami // OR the WS dropped briefly (state intact). Either way, sending `subscribe` // with `afterSeq: lastSeenSeq` is the right thing: the server replays // missed chunks if it has them, otherwise starts fresh. - rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (isTrusted) => { if (!isTrusted) return for (const [key, reader] of readers) { @@ -142,7 +143,7 @@ export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreami }) } else { - const off = rpc.events.on('rpc:is-trusted:updated', (trusted) => { + const off = rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (trusted) => { if (trusted) { off() if (readers.has(key) && !reader.cancelled && !reader.done) { diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index b35bd57a..99583045 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -1,3 +1,7 @@ +import { DEVFRAME_EVENTS } from './events' + +export { DEVFRAME_EVENTS } from './events' + // Devframe runtime routes and static output conventions. export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' @@ -88,7 +92,7 @@ export const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = 'devframe_viewer_origin_ * (`@devframes/hub-ui` does, in its iframe view). Payload shape: * `RemoteAssetsErrorMessage` (`devframe/types`). */ -export const DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE = 'devframe:remote-assets-error' +export const DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE: string = DEVFRAME_EVENTS.postMessage.remoteAssetsError /** * Prefix that marks an RPC method as callable before a connection is diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts new file mode 100644 index 00000000..da8e6d57 --- /dev/null +++ b/packages/devframe/src/events.ts @@ -0,0 +1,64 @@ +/** + * Centralized registry of the core devframe event names — the node-side host + * bus events, the client RPC connection events, and the server→client + * broadcast notifications — so these names live in one place instead of + * scattered string literals. + * + * **Keep this in sync with [`docs/guide/events.md`](../../../docs/guide/events.md)** + * (the "Core devframe events" section): every name here appears in that page's + * tables, and every name there resolves to an entry here. Add, rename, or + * remove a name in both places in the same change, and reference + * `DEVFRAME_EVENTS.*` from call sites instead of re-typing a literal. + * + * This map covers **notifications** (events, broadcasts). The request/response + * RPC endpoints of the shared-state, streaming, and auth-handshake protocols + * (`devframe:rpc:server-state:*`, `devframe:streaming:subscribe`, + * `anonymous:devframe:auth`, …) are defined at their handlers and typed in + * `types/rpc-augments.ts`; they aren't events and stay out of this map. + * + * The `EventEmitter` maps (`RpcClientEvents`, `DevframeAgentHostEvents`) and the + * `DevframeRpcClientFunctions` augmentation declare these names as type-level + * keys (a literal is unavoidable in a type position); those declarations mirror + * this map and move with it. + */ +export const DEVFRAME_EVENTS = { + /** + * Node-side host `EventEmitter` events. The agent host (`ctx.agent.events`) + * emits these as its tool/resource surface changes; protocol adapters (e.g. + * MCP) subscribe to re-publish their manifest. + */ + bus: { + agentManifestChanged: 'agent:manifest:changed', + agentToolRegistered: 'agent:tool:registered', + agentToolUnregistered: 'agent:tool:unregistered', + agentResourceRegistered: 'agent:resource:registered', + agentResourceUnregistered: 'agent:resource:unregistered', + }, + /** + * Client-side RPC connection `EventEmitter` events (`rpc.events`) a UI + * subscribes to for connection lifecycle and error surfacing. + */ + client: { + isTrustedUpdated: 'rpc:is-trusted:updated', + error: 'rpc:error', + connectionStatus: 'connection:status', + connectionError: 'connection:error', + }, + /** + * Broadcast notifications the server pushes to clients (server → client), + * `devframe:` prefix. The paired request methods (subscribe/get/set/…) are + * RPC endpoints, not events, and are omitted deliberately. + */ + broadcast: { + authRevoked: 'devframe:auth:revoked', + clientStateUpdated: 'devframe:rpc:client-state:updated', + clientStatePatch: 'devframe:rpc:client-state:patch', + streamingChunk: 'devframe:streaming:chunk', + streamingEnd: 'devframe:streaming:end', + streamingUploadCancel: 'devframe:streaming:upload-cancel', + }, + /** `postMessage` channels the runtime posts across window boundaries. */ + postMessage: { + remoteAssetsError: 'devframe:remote-assets-error', + }, +} as const diff --git a/packages/devframe/src/node/auth/revoke.ts b/packages/devframe/src/node/auth/revoke.ts index 235e099d..ce742b2e 100644 --- a/packages/devframe/src/node/auth/revoke.ts +++ b/packages/devframe/src/node/auth/revoke.ts @@ -2,6 +2,7 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { RpcFunctionsHostImpl } from '../host-functions' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' +import { DEVFRAME_EVENTS } from '../../events' /** * Flip `isTrusted` to false on any live WS clients connected with `token` @@ -30,7 +31,7 @@ export async function revokeActiveConnectionsForToken( return await rpcHost.broadcast({ - method: 'devframe:auth:revoked', + method: DEVFRAME_EVENTS.broadcast.authRevoked, args: [], filter: client => affectedSessionIds.has(client.$meta.id), }) diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index c3e6bf90..fabd785b 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -16,6 +16,7 @@ import type { RpcFunctionAgentOptions, } from 'devframe/types' import { createEventEmitter } from 'devframe/utils/events' +import { DEVFRAME_EVENTS } from '../events' import { coerceAgentPositionalArgs } from './agent-args' import { diagnostics } from './diagnostics' @@ -48,7 +49,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { ) { // Watch the RPC host for new `agent`-flagged definitions. this._rpcUnsubscribe = context.rpc.onChanged(() => { - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) }) } @@ -57,8 +58,8 @@ export class DevframeAgentHost implements DevframeAgentHostType { const tool = this._projectTool(input) this.tools.set(tool.id, { tool, handler: input.handler }) - this.events.emit('agent:tool:registered', tool) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentToolRegistered, tool) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) return { unregister: () => this.unregisterTool(tool.id), @@ -68,25 +69,25 @@ export class DevframeAgentHost implements DevframeAgentHostType { unregisterTool(id: string): boolean { const existed = this.tools.delete(id) if (existed) { - this.events.emit('agent:tool:unregistered', id) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentToolUnregistered, id) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return existed } registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle { this.providers.add(provider) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) const notifyChanged = (): void => { if (this.providers.has(provider)) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return { notifyChanged, unregister: () => { if (this.providers.delete(provider)) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) }, } } @@ -103,8 +104,8 @@ export class DevframeAgentHost implements DevframeAgentHostType { uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, } this.resources.set(resource.id, { resource, read: input.read }) - this.events.emit('agent:resource:registered', resource) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, resource) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) return { unregister: () => this.unregisterResource(resource.id), @@ -114,8 +115,8 @@ export class DevframeAgentHost implements DevframeAgentHostType { unregisterResource(id: string): boolean { const existed = this.resources.delete(id) if (existed) { - this.events.emit('agent:resource:unregistered', id) - this.events.emit('agent:manifest:changed') + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUnregistered, id) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return existed } diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index 27bdc4d8..300ecff5 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -2,6 +2,7 @@ import type { RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } f import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import { createSharedState } from 'devframe/utils/shared-state' import { createDebug } from 'obug' +import { DEVFRAME_EVENTS } from '../events' import { diagnostics } from './diagnostics' const debug = createDebug('devframe:rpc:state:changed') @@ -22,7 +23,7 @@ export function createRpcSharedStateServerHost( if (patches) { debug('patch', { key, syncId }) rpc.broadcast({ - method: 'devframe:rpc:client-state:patch', + method: DEVFRAME_EVENTS.broadcast.clientStatePatch, args: [key, patches, syncId], filter: client => client.$meta.subscribedStates.has(key), }) @@ -30,7 +31,7 @@ export function createRpcSharedStateServerHost( else { debug('updated', { key, syncId }) rpc.broadcast({ - method: 'devframe:rpc:client-state:updated', + method: DEVFRAME_EVENTS.broadcast.clientStateUpdated, args: [key, fullState, syncId], filter: client => client.$meta.subscribedStates.has(key), }) diff --git a/packages/devframe/src/node/rpc-streaming.ts b/packages/devframe/src/node/rpc-streaming.ts index 3e1d2d87..77ec909a 100644 --- a/packages/devframe/src/node/rpc-streaming.ts +++ b/packages/devframe/src/node/rpc-streaming.ts @@ -8,6 +8,7 @@ import type { import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' import { createDebug } from 'obug' +import { DEVFRAME_EVENTS } from '../events' import { diagnostics } from './diagnostics' const debug = createDebug('devframe:rpc:streaming') @@ -118,7 +119,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin for (const buffered of record.sink.buffer) { if (buffered.seq > afterSeq) { rpc.broadcast({ - method: 'devframe:streaming:chunk', + method: DEVFRAME_EVENTS.broadcast.streamingChunk, args: [channelName, id, buffered.seq, buffered.chunk], event: true, optional: true, @@ -128,7 +129,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin } if (record.sink.closed) { rpc.broadcast({ - method: 'devframe:streaming:end', + method: DEVFRAME_EVENTS.broadcast.streamingEnd, args: [channelName, id, undefined], event: true, optional: true, @@ -250,7 +251,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin record.unbinders.push( sink.events.on('chunk', (seq, chunk) => { rpc.broadcast({ - method: 'devframe:streaming:chunk', + method: DEVFRAME_EVENTS.broadcast.streamingChunk, args: [name, sink.id, seq, chunk], event: true, optional: true, @@ -261,7 +262,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin record.unbinders.push( sink.events.on('end', (error) => { rpc.broadcast({ - method: 'devframe:streaming:end', + method: DEVFRAME_EVENTS.broadcast.streamingEnd, args: [name, sink.id, error], event: true, optional: true, @@ -304,7 +305,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin if (!targetMeta) return rpc.broadcast({ - method: 'devframe:streaming:upload-cancel', + method: DEVFRAME_EVENTS.broadcast.streamingUploadCancel, args: [name, reader.id], event: true, optional: true, diff --git a/packages/devframe/src/utils/serve-static.ts b/packages/devframe/src/utils/serve-static.ts index 2dc50e95..c24e698b 100644 --- a/packages/devframe/src/utils/serve-static.ts +++ b/packages/devframe/src/utils/serve-static.ts @@ -8,7 +8,7 @@ import { Readable } from 'node:stream' import { defineHandler, H3 } from 'h3' import { lookup } from 'mrmime' import { extname, join, normalize, resolve, sep } from 'pathe' -import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '../constants' +import { DEVFRAME_EVENTS } from '../events' /** * What the static-serving engine accepts: a local directory, or a resolved @@ -322,7 +322,7 @@ function remoteErrorPage(pkg: string, version: string, reason: string): string { const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>') const name = esc(pkg) const ver = esc(version) - const message: RemoteAssetsErrorMessage = { type: DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, package: pkg, version, reason } + const message: RemoteAssetsErrorMessage = { type: DEVFRAME_EVENTS.postMessage.remoteAssetsError, package: pkg, version, reason } // `` inside the JSON would end the block early; `<` is the only // character that can do that, and escaping it keeps the literal valid JS. const payload = JSON.stringify(message).replace(/ props.entry.launcher.terminalSessionId) function viewInTerminal() { if (!terminalSessionId.value) return - props.context.rpc.call('hub:docks:activate', { + props.context.rpc.call(HUB_EVENTS.rpc.docksActivate, { dockId: TERMINALS_DOCK_ID, params: { sessionId: terminalSessionId.value }, }) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index cf1574bb..6665e931 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -5,7 +5,7 @@ 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 { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '@devframes/hub/constants' +import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants' import { useBranding } from './branding' @@ -376,7 +376,7 @@ export async function createDocksContext( // ourselves. The target dock (e.g. Terminals) reads `activation.params` to // focus a specific session. rpc.client.register({ - name: 'devframe:docks:activate' satisfies keyof DevframeRpcClientFunctions, + name: HUB_EVENTS.broadcast.docksActivate satisfies keyof DevframeRpcClientFunctions, type: 'action', handler: (activation: { dockId: string, params?: Record }) => { if (activation?.dockId) diff --git a/packages/hub/src/client/frame-nav.ts b/packages/hub/src/client/frame-nav.ts index f44b7dc0..19e89d85 100644 --- a/packages/hub/src/client/frame-nav.ts +++ b/packages/hub/src/client/frame-nav.ts @@ -1,5 +1,6 @@ import type { DevframeDockEntryIcon, DevframeViewIframe, NavTarget } from '../types/docks' import type { DockRegistration, DocksEntriesContext } from './docks' +import { HUB_EVENTS } from '../events' /** * Shared-iframe soft navigation — the viewer-side half of a host↔iframe @@ -22,7 +23,7 @@ import type { DockRegistration, DocksEntriesContext } from './docks' */ /** `postMessage` channel tag shared by both halves of the protocol. */ -export const FRAME_NAV_CHANNEL = 'devframe:frame-nav' +export const FRAME_NAV_CHANNEL: string = HUB_EVENTS.postMessage.frameNav /** Protocol version. */ export const FRAME_NAV_VERSION = 1 diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index c16d3884..e8ba98fd 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -27,15 +27,16 @@ import type { DockRenderer, DockRendererManifest, DockRenderersContext } from '. import { connectDevframe } from 'devframe/client' import { createEventEmitter } from 'devframe/utils/events' import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '../constants' +import { HUB_EVENTS } from '../events' import { getDevframeClientContext, setDevframeClientContext } from './context' import { attachFrameNavClient } from './frame-nav' import { createMessagesClient } from './messages' import { createDockRenderersContext } from './renderers' -const DOCKS_STATE_KEY = 'devframe:docks' -const COMMANDS_STATE_KEY = 'devframe:commands' -const USER_SETTINGS_STATE_KEY = 'devframe:user-settings' -const DOCKS_ACTIVATE_EVENT = 'devframe:docks:activate' +const DOCKS_STATE_KEY = HUB_EVENTS.sharedState.docks +const COMMANDS_STATE_KEY = HUB_EVENTS.sharedState.commands +const USER_SETTINGS_STATE_KEY = HUB_EVENTS.sharedState.userSettings +const DOCKS_ACTIVATE_EVENT = HUB_EVENTS.broadcast.docksActivate export interface DevframeClientHostOptions { /** diff --git a/packages/hub/src/constants.ts b/packages/hub/src/constants.ts index f169e761..f1340ddb 100644 --- a/packages/hub/src/constants.ts +++ b/packages/hub/src/constants.ts @@ -1,6 +1,8 @@ import type { DevframeDocksUserSettings } from './types/settings' import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash } from 'ufo' +import { HUB_EVENTS } from './events' +export { HUB_EVENTS } from './events' export * from 'devframe/constants' /** Default mount base for a hub instance — one namespace, one catch-all. */ @@ -46,7 +48,7 @@ export const DEFAULT_CATEGORIES_ORDER: Record = { * `type`, published by `initHub({ renderers })` and consumed by every * hub-aware client (the headless client host and viewers alike). */ -export const DOCK_RENDERERS_STATE_KEY = 'devframe:dock-renderers' +export const DOCK_RENDERERS_STATE_KEY: string = HUB_EVENTS.sharedState.dockRenderers export const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings = () => ({ docksHidden: [], diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts new file mode 100644 index 00000000..0113e14c --- /dev/null +++ b/packages/hub/src/events.ts @@ -0,0 +1,70 @@ +/** + * Centralized registry of every event, broadcast, RPC method, shared-state + * key, and channel name the hub uses — the single source of truth that keeps + * these names out of scattered string literals. + * + * **Keep this in sync with [`docs/guide/events.md`](../../../docs/guide/events.md)** + * (the Hub Events Reference): every name here appears in that page's tables, and + * every name there resolves to an entry here. Add, rename, or remove a name in + * both places in the same change, and reference `HUB_EVENTS.*` from call sites + * instead of re-typing a literal. + * + * The `.events` EventEmitter maps in `types/{docks,terminals,messages,commands}.ts` + * and the RPC augmentation interfaces in `node/context.ts` declare these same + * names as type-level keys (a literal is unavoidable in a type position); those + * declarations mirror this map and move with it. + */ +export const HUB_EVENTS = { + /** + * Internal node `EventEmitter` events on `ctx..events`. Emitted + * and consumed inside the node process (chiefly by `createHubContext`, which + * fans them out onto the wire); they never cross to the browser. + */ + bus: { + docksEntryUpdated: 'docks:entry:updated', + docksActivate: 'docks:activate', + terminalsSessionUpdated: 'terminals:session:updated', + messagesAdded: 'messages:added', + messagesUpdated: 'messages:updated', + messagesRemoved: 'messages:removed', + messagesCleared: 'messages:cleared', + commandsRegistered: 'commands:registered', + commandsUnregistered: 'commands:unregistered', + }, + /** Server RPC methods a connected client calls (client → server), `hub:` prefix. */ + rpc: { + docksActivate: 'hub:docks:activate', + commandsExecute: 'hub:commands:execute', + messagesAdd: 'hub:messages:add', + messagesUpdate: 'hub:messages:update', + messagesRemove: 'hub:messages:remove', + messagesClear: 'hub:messages:clear', + terminalsWrite: 'hub:terminals:write', + terminalsResize: 'hub:terminals:resize', + terminalsTerminate: 'hub:terminals:terminate', + terminalsRestart: 'hub:terminals:restart', + terminalsRemove: 'hub:terminals:remove', + }, + /** Broadcast notifications the server pushes to clients (server → client), `devframe:` prefix. */ + broadcast: { + docksActivate: 'devframe:docks:activate', + terminalsUpdated: 'devframe:terminals:updated', + messagesUpdated: 'devframe:messages:updated', + }, + /** Shared-state slot keys a hub-aware client reads (server → client), `devframe:` prefix. */ + sharedState: { + docks: 'devframe:docks', + docksActive: 'devframe:docks:active', + commands: 'devframe:commands', + userSettings: 'devframe:user-settings', + dockRenderers: 'devframe:dock-renderers', + }, + /** Streaming channel ids (server → client), `devframe:` prefix. */ + stream: { + terminals: 'devframe:terminals', + }, + /** `postMessage` channels for host ↔ iframe protocols, `devframe:` prefix. */ + postMessage: { + frameNav: 'devframe:frame-nav', + }, +} as const diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index c0539638..6aee0ffc 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -8,6 +8,7 @@ import type { InstallDevframeOptions } from './install-devframe' import { createHostContext } from 'devframe/node' import { getInternalContext } from 'devframe/node/hub-internals' import { debounce } from 'perfect-debounce' +import { HUB_EVENTS } from '../events' import { DevframeCommandsHost as CommandsHostImpl } from './host-commands' import { DevframeDocksHost as DocksHostImpl } from './host-docks' import { DevframeMessagesHost as MessagesHostImpl } from './host-messages' @@ -151,11 +152,11 @@ export async function createHubContext(options: CreateHubContextOptions): Promis const debounceMs = options.mode === 'build' ? 0 : 10 - const docksSharedState = await context.rpc.sharedState.get('devframe:docks', { initialValue: [] }) + const docksSharedState = await context.rpc.sharedState.get(HUB_EVENTS.sharedState.docks, { initialValue: [] }) const refreshDocks = debounce(() => { docksSharedState.mutate(() => docks.values()) }, debounceMs) - docks.events.on('docks:entry:updated', refreshDocks) + docks.events.on(HUB_EVENTS.bus.docksEntryUpdated, refreshDocks) // A remote iframe dock registered before the WS transport finishes binding // (the common case: `initHub` installs devframes — and their docks — before // resolving an async side-car/shared-server port) gets projected without a @@ -171,46 +172,46 @@ export async function createHubContext(options: CreateHubContextOptions): Promis // switches its active dock — and into a shared-state slot, so a dock that // only mounts *because* of the switch still converges on the request. const activeDockSharedState = await context.rpc.sharedState.get( - 'devframe:docks:active', + HUB_EVENTS.sharedState.docksActive, { initialValue: { activation: null } }, ) - docks.events.on('docks:activate', (activation) => { + docks.events.on(HUB_EVENTS.bus.docksActivate, (activation) => { activeDockSharedState.mutate((state) => { state.activation = activation }) context.rpc.broadcast({ - method: 'devframe:docks:activate', + method: HUB_EVENTS.broadcast.docksActivate, args: [activation], }) }) const broadcastTerminals = debounce(() => { context.rpc.broadcast({ - method: 'devframe:terminals:updated', + method: HUB_EVENTS.broadcast.terminalsUpdated, args: [], }) docksSharedState.mutate(() => docks.values()) }, debounceMs) - terminals.events.on('terminals:session:updated', broadcastTerminals) + terminals.events.on(HUB_EVENTS.bus.terminalsSessionUpdated, broadcastTerminals) const broadcastMessages = debounce(() => { context.rpc.broadcast({ - method: 'devframe:messages:updated', + method: HUB_EVENTS.broadcast.messagesUpdated, args: [], }) docksSharedState.mutate(() => docks.values()) }, debounceMs) - messages.events.on('messages:added', broadcastMessages) - messages.events.on('messages:updated', broadcastMessages) - messages.events.on('messages:removed', broadcastMessages) - messages.events.on('messages:cleared', broadcastMessages) + messages.events.on(HUB_EVENTS.bus.messagesAdded, broadcastMessages) + messages.events.on(HUB_EVENTS.bus.messagesUpdated, broadcastMessages) + messages.events.on(HUB_EVENTS.bus.messagesRemoved, broadcastMessages) + messages.events.on(HUB_EVENTS.bus.messagesCleared, broadcastMessages) - const commandsSharedState = await context.rpc.sharedState.get('devframe:commands', { initialValue: [] }) + const commandsSharedState = await context.rpc.sharedState.get(HUB_EVENTS.sharedState.commands, { initialValue: [] }) const syncCommands = debounce(() => { commandsSharedState.mutate(() => commands.list()) }, debounceMs) - commands.events.on('commands:registered', syncCommands) - commands.events.on('commands:unregistered', syncCommands) + commands.events.on(HUB_EVENTS.bus.commandsRegistered, syncCommands) + commands.events.on(HUB_EVENTS.bus.commandsUnregistered, syncCommands) commandsSharedState.mutate(() => commands.list()) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index a1113536..2941d2f2 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -8,6 +8,7 @@ import type { import type { DevframeHubContext } from './context' import { coerceAgentPositionalArgs } from 'devframe/internal' import { createEventEmitter } from 'devframe/utils/events' +import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined { @@ -76,7 +77,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { validateCommandIds(this.commands, command) this.validateAgentExposure(command) this.commands.set(command.id, command) - this.events.emit('commands:registered', this.toSerializable(command)) + this.events.emit(HUB_EVENTS.bus.commandsRegistered, this.toSerializable(command)) this.agentProvider?.notifyChanged() return { @@ -97,7 +98,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { validateCommandIds(this.commands, next, existing.id) this.validateAgentExposure(next) Object.assign(existing, patch) - this.events.emit('commands:registered', this.toSerializable(existing)) + this.events.emit(HUB_EVENTS.bus.commandsRegistered, this.toSerializable(existing)) this.agentProvider?.notifyChanged() }, unregister: () => this.unregister(command.id), @@ -107,7 +108,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { unregister(id: string): boolean { const deleted = this.commands.delete(id) if (deleted) { - this.events.emit('commands:unregistered', id) + this.events.emit(HUB_EVENTS.bus.commandsUnregistered, id) this.agentProvider?.notifyChanged() } return deleted diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 41418da8..cd9b9b22 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -15,6 +15,7 @@ import { getInternalContext } from 'devframe/node/hub-internals' import { createEventEmitter } from 'devframe/utils/events' import { join } from 'pathe' import { DEFAULT_STATE_USER_SETTINGS } from '../constants' +import { HUB_EVENTS } from '../events' import { buildRemoteConnectionUrl } from '../remote-url' import { diagnostics } from './diagnostics' @@ -44,7 +45,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { ) {} async init() { - this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { + this.userSettings = await this.context.rpc.sharedState.get(HUB_EVENTS.sharedState.userSettings, { sharedState: createStorage({ // Personal dock layout/preferences: per-checkout private state. filepath: join(this.context.host.getStorageDir('project'), 'settings.json'), @@ -91,7 +92,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { this.validateGroupMembership(view) this.prepareRemoteRegistration(view) this.views.set(view.id, view) - this.events.emit('docks:entry:updated', view) + this.events.emit(HUB_EVENTS.bus.docksEntryUpdated, view) return { update: (patch) => { @@ -115,7 +116,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { this.validateGroupMembership(view) this.prepareRemoteRegistration(view) this.views.set(view.id, view) - this.events.emit('docks:entry:updated', view) + this.events.emit(HUB_EVENTS.bus.docksEntryUpdated, view) } activate(dockId: string, params?: Record): void { @@ -125,7 +126,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { // rather than fatal. if (!this.views.has(dockId)) diagnostics.DF8107({ id: dockId }) - this.events.emit('docks:activate', { dockId, params }) + this.events.emit(HUB_EVENTS.bus.docksActivate, { dockId, params }) } private validateGroupMembership(view: DevframeDockUserEntry): void { diff --git a/packages/hub/src/node/host-messages.ts b/packages/hub/src/node/host-messages.ts index 75d87ea7..14afed05 100644 --- a/packages/hub/src/node/host-messages.ts +++ b/packages/hub/src/node/host-messages.ts @@ -9,6 +9,7 @@ import type { import type { DevframeHubContext } from './context' import { createEventEmitter } from 'devframe/utils/events' import { nanoid } from 'devframe/utils/nanoid' +import { HUB_EVENTS } from '../events' const MAX_ENTRIES = 1000 const MAX_REMOVALS = 1000 @@ -69,7 +70,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.set(entry.id, entry) this.lastModified.set(entry.id, this._tick()) - this.events.emit('messages:added', entry) + this.events.emit(HUB_EVENTS.bus.messagesAdded, entry) if (entry.autoDelete) { this._autoDeleteTimers.set(entry.id, setTimeout(() => { @@ -95,7 +96,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.set(id, updated) this.lastModified.set(id, this._tick()) - this.events.emit('messages:updated', updated) + this.events.emit(HUB_EVENTS.bus.messagesUpdated, updated) // Reset autoDelete timer if changed if (patch.autoDelete !== undefined) { @@ -123,7 +124,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.entries.delete(id) this.lastModified.delete(id) this._recordRemoval(id, this._tick()) - this.events.emit('messages:removed', id) + this.events.emit(HUB_EVENTS.bus.messagesRemoved, id) } info(message: string, extra?: DevframeMessageShortcutInput): Promise { @@ -155,7 +156,7 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this._recordRemoval(id, tick) this.entries.clear() this.lastModified.clear() - this.events.emit('messages:cleared') + this.events.emit(HUB_EVENTS.bus.messagesCleared) } listSince(since?: number | null): DevframeMessagesListDelta { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 8b899d10..d1b3b542 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -16,6 +16,7 @@ import type { import type { DevframeHubContext } from './context' import process from 'node:process' import { createEventEmitter } from 'devframe/utils/events' +import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' type PartialWithoutId = Partial & { id: string } @@ -24,7 +25,7 @@ type PartialWithoutId = Partial & { id: string } * Channel name used for terminal stream output. Stable, well-known so * hub-aware clients can subscribe by name. */ -const TERMINAL_STREAM_CHANNEL = 'devframe:terminals' as const +const TERMINAL_STREAM_CHANNEL = HUB_EVENTS.stream.terminals const TERMINAL_REPLAY_WINDOW = 1000 /** Max chunks retained in the per-session scrollback buffer (bounded like the replay window). */ const TERMINAL_BUFFER_LIMIT = 1000 @@ -71,7 +72,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { } this.sessions.set(session.id, session) this.bindStream(session) - this.events.emit('terminals:session:updated', session) + this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session) return session } @@ -83,13 +84,13 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { Object.assign(session, patch) this.sessions.set(patch.id, session) this.bindStream(session) - this.events.emit('terminals:session:updated', session) + this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session) } remove(session: DevframeTerminalSession): void { this._boundStreams.get(session.id)?.dispose() this.sessions.delete(session.id) - this.events.emit('terminals:session:updated', session) + this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session) this._boundStreams.delete(session.id) } @@ -185,7 +186,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (session.status === next) return session.status = next - this.events.emit('terminals:session:updated', session) + this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session) } const closeStream = () => { @@ -376,7 +377,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (session.status === next) return session.status = next - this.events.emit('terminals:session:updated', session) + this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session) } const closeStream = () => { diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index 3d1f1eb0..03fa34de 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -5,6 +5,7 @@ import type { DevframePtyTerminalSession, } from '../types/terminals' import { defineHubRpcFunction } from '../define' +import { HUB_EVENTS } from '../events' import { diagnostics } from './diagnostics' /** @@ -52,7 +53,7 @@ function resolveControllableSession( * from the shared state and dispatch by id via this RPC. */ export const hubCommandsExecute = defineHubRpcFunction({ - name: 'hub:commands:execute', + name: HUB_EVENTS.rpc.commandsExecute, type: 'action', setup: context => ({ async handler(id: string, ...args: any[]) { @@ -71,7 +72,7 @@ export const hubCommandsExecute = defineHubRpcFunction({ * dock client script can report into the same feed the server writes to. */ export const hubMessagesAdd = defineHubRpcFunction({ - name: 'hub:messages:add', + name: HUB_EVENTS.rpc.messagesAdd, type: 'action', jsonSerializable: true, setup: context => ({ @@ -84,7 +85,7 @@ export const hubMessagesAdd = defineHubRpcFunction({ /** `hub:messages:update` — Patch a message by id; returns the updated entry (or `undefined`). */ export const hubMessagesUpdate = defineHubRpcFunction({ - name: 'hub:messages:update', + name: HUB_EVENTS.rpc.messagesUpdate, type: 'action', jsonSerializable: true, setup: context => ({ @@ -96,7 +97,7 @@ export const hubMessagesUpdate = defineHubRpcFunction({ /** `hub:messages:remove` — Remove a message by id. */ export const hubMessagesRemove = defineHubRpcFunction({ - name: 'hub:messages:remove', + name: HUB_EVENTS.rpc.messagesRemove, type: 'action', setup: context => ({ async handler(id: string): Promise { @@ -107,7 +108,7 @@ export const hubMessagesRemove = defineHubRpcFunction({ /** `hub:messages:clear` — Remove every message. */ export const hubMessagesClear = defineHubRpcFunction({ - name: 'hub:messages:clear', + name: HUB_EVENTS.rpc.messagesClear, type: 'action', setup: context => ({ async handler(): Promise { @@ -122,7 +123,7 @@ export const hubMessagesClear = defineHubRpcFunction({ * terminals plugin) drive a session owned by another plugin. */ export const hubTerminalsWrite = defineHubRpcFunction({ - name: 'hub:terminals:write', + name: HUB_EVENTS.rpc.terminalsWrite, type: 'action', setup: context => ({ async handler(id: string, data: string): Promise { @@ -133,7 +134,7 @@ export const hubTerminalsWrite = defineHubRpcFunction({ /** `hub:terminals:resize` — Resize an interactive PTY session by id. */ export const hubTerminalsResize = defineHubRpcFunction({ - name: 'hub:terminals:resize', + name: HUB_EVENTS.rpc.terminalsResize, type: 'action', setup: context => ({ async handler(id: string, cols: number, rows: number): Promise { @@ -149,7 +150,7 @@ export const hubTerminalsResize = defineHubRpcFunction({ * force-kill a session owned by another plugin. */ export const hubTerminalsTerminate = defineHubRpcFunction({ - name: 'hub:terminals:terminate', + name: HUB_EVENTS.rpc.terminalsTerminate, type: 'action', setup: context => ({ async handler(id: string): Promise { @@ -164,7 +165,7 @@ export const hubTerminalsTerminate = defineHubRpcFunction({ * elsewhere. */ export const hubTerminalsRestart = defineHubRpcFunction({ - name: 'hub:terminals:restart', + name: HUB_EVENTS.rpc.terminalsRestart, type: 'action', setup: context => ({ async handler(id: string): Promise { @@ -182,7 +183,7 @@ export const hubTerminalsRestart = defineHubRpcFunction({ * terminal UI discard a stopped aggregated session. */ export const hubTerminalsRemove = defineHubRpcFunction({ - name: 'hub:terminals:remove', + name: HUB_EVENTS.rpc.terminalsRemove, type: 'action', setup: context => ({ async handler(id: string): Promise { @@ -210,7 +211,7 @@ export const hubTerminalsRemove = defineHubRpcFunction({ * mounts in response still converges on it). */ export const hubDocksActivate = defineHubRpcFunction({ - name: 'hub:docks:activate', + name: HUB_EVENTS.rpc.docksActivate, type: 'action', setup: context => ({ async handler(input: { dockId: string, params?: Record }): Promise { diff --git a/plugins/messages/src/spa/dev-host.ts b/plugins/messages/src/spa/dev-host.ts index 6baf6658..9be658e3 100644 --- a/plugins/messages/src/spa/dev-host.ts +++ b/plugins/messages/src/spa/dev-host.ts @@ -1,5 +1,6 @@ import type { DevframeMessagesHost as DevframeMessagesHostType } from '@devframes/hub/types' import type { DevframeDefinition, DevframeNodeContext } from 'devframe' +import { HUB_EVENTS } from '@devframes/hub/constants' import { defineDevframe } from 'devframe' import { MESSAGES_UPDATED_EVENT } from '../constants' import { createMessagesDevframe } from '../index' @@ -31,10 +32,10 @@ function wireBroadcast(ctx: DevframeNodeContext, messages: DevframeMessagesHostT const broadcast = (): void => { ctx.rpc.broadcast({ method: MESSAGES_UPDATED_EVENT, args: [] }) } - messages.events.on('messages:added', broadcast) - messages.events.on('messages:updated', broadcast) - messages.events.on('messages:removed', broadcast) - messages.events.on('messages:cleared', broadcast) + messages.events.on(HUB_EVENTS.bus.messagesAdded, broadcast) + messages.events.on(HUB_EVENTS.bus.messagesUpdated, broadcast) + messages.events.on(HUB_EVENTS.bus.messagesRemoved, broadcast) + messages.events.on(HUB_EVENTS.bus.messagesCleared, broadcast) } function seedDemoMessages(messages: DevframeMessagesHostType): void { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 823b3b0e..4d2876e2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -10,6 +10,50 @@ export declare const DEFAULT_CATEGORIES_ORDER: Record; export declare const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings; export declare const DEVFRAMES_HUB_BASE: string; export declare const DOCK_RENDERERS_STATE_KEY: string; +export declare const HUB_EVENTS: { + readonly bus: { + readonly docksEntryUpdated: "docks:entry:updated"; + readonly docksActivate: "docks:activate"; + readonly terminalsSessionUpdated: "terminals:session:updated"; + readonly messagesAdded: "messages:added"; + readonly messagesUpdated: "messages:updated"; + readonly messagesRemoved: "messages:removed"; + readonly messagesCleared: "messages:cleared"; + readonly commandsRegistered: "commands:registered"; + readonly commandsUnregistered: "commands:unregistered"; + }; + readonly rpc: { + readonly docksActivate: "hub:docks:activate"; + readonly commandsExecute: "hub:commands:execute"; + readonly messagesAdd: "hub:messages:add"; + readonly messagesUpdate: "hub:messages:update"; + readonly messagesRemove: "hub:messages:remove"; + readonly messagesClear: "hub:messages:clear"; + readonly terminalsWrite: "hub:terminals:write"; + readonly terminalsResize: "hub:terminals:resize"; + readonly terminalsTerminate: "hub:terminals:terminate"; + readonly terminalsRestart: "hub:terminals:restart"; + readonly terminalsRemove: "hub:terminals:remove"; + }; + readonly broadcast: { + readonly docksActivate: "devframe:docks:activate"; + readonly terminalsUpdated: "devframe:terminals:updated"; + readonly messagesUpdated: "devframe:messages:updated"; + }; + readonly sharedState: { + readonly docks: "devframe:docks"; + readonly docksActive: "devframe:docks:active"; + readonly commands: "devframe:commands"; + readonly userSettings: "devframe:user-settings"; + readonly dockRenderers: "devframe:dock-renderers"; + }; + readonly stream: { + readonly terminals: "devframe:terminals"; + }; + readonly postMessage: { + readonly frameNav: "devframe:frame-nav"; + }; +}; // #endregion // #region Re-exports diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js index 2990e6b6..e8477ff0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js @@ -10,6 +10,7 @@ export var DEFAULT_CATEGORIES_ORDER /* const */ export var DEFAULT_STATE_USER_SETTINGS /* const */ export var DEVFRAMES_HUB_BASE /* const */ export var DOCK_RENDERERS_STATE_KEY /* const */ +export var HUB_EVENTS /* const */ // #endregion // #region Re-exports diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 9938da9f..24f03f7f 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -11,6 +11,32 @@ export declare const DEVFRAME_AUTH_TOKEN_QUERY_PARAM: string; export declare const DEVFRAME_CONNECTION_KEY: string; export declare const DEVFRAME_CONNECTION_META_FILENAME: string; export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; +export declare const DEVFRAME_EVENTS: { + readonly bus: { + readonly agentManifestChanged: "agent:manifest:changed"; + readonly agentToolRegistered: "agent:tool:registered"; + readonly agentToolUnregistered: "agent:tool:unregistered"; + readonly agentResourceRegistered: "agent:resource:registered"; + readonly agentResourceUnregistered: "agent:resource:unregistered"; + }; + readonly client: { + readonly isTrustedUpdated: "rpc:is-trusted:updated"; + readonly error: "rpc:error"; + readonly connectionStatus: "connection:status"; + readonly connectionError: "connection:error"; + }; + readonly broadcast: { + readonly authRevoked: "devframe:auth:revoked"; + readonly clientStateUpdated: "devframe:rpc:client-state:updated"; + readonly clientStatePatch: "devframe:rpc:client-state:patch"; + readonly streamingChunk: "devframe:streaming:chunk"; + readonly streamingEnd: "devframe:streaming:end"; + readonly streamingUploadCancel: "devframe:streaming:upload-cancel"; + }; + readonly postMessage: { + readonly remoteAssetsError: "devframe:remote-assets-error"; + }; +}; export declare const DEVFRAME_MCP_ROUTE: string; export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index db97b2c1..e414f894 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -11,6 +11,7 @@ export var DEVFRAME_AUTH_TOKEN_QUERY_PARAM /* const */ export var DEVFRAME_CONNECTION_KEY /* const */ export var DEVFRAME_CONNECTION_META_FILENAME /* const */ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ +export var DEVFRAME_EVENTS /* const */ export var DEVFRAME_MCP_ROUTE /* const */ export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE /* const */