Skip to content

Commit 5c51870

Browse files
committed
refactor(devframe,hub,hub-ui): generic ctx.configs API, drop host-page branding channels
Follows up on review feedback: - Adds ctx.configs (DevframeConfigsHost) as a core devframe context primitive — the generic API for contributing to a context's own ConnectionMeta.configs, resolved once (instance-shell) after every contributor has run and baked into the served meta for every host adapter, not just the hub. Contributors own their own merge semantics via an (current) => next updater. - installDevframe now calls ctx.configs.contribute('dock', ...) instead of a docks-specific contributeDockConfig; dockConfig/contributeDockConfig are removed from DevframeDocksHost — dock-bar config aggregation isn't a docks-registry concern. - DevframeHubUi.settings renamed to configs, matching the ConnectionMeta.configs.ui wire shape it feeds. - hub-ui's resolveBranding drops the host-page override channels (window.__DEVFRAME_BRANDING__, <script data-*>, ?query params) — ConnectionMeta already has its own cross-realm propagation, so branding needs no globals or query params of its own. Built with the help of an agent.
1 parent 78bf2e6 commit 5c51870

24 files changed

Lines changed: 206 additions & 195 deletions

docs/guide/build-your-own-hub-ui.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface DevframeHubUi {
1616
viewer?: { distDir: string } // a standalone SPA served at the hub base
1717
embedded?: { entry: string } // a self-contained bootstrap at <base>embedded.js
1818
assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
19-
settings?: () => Record<string, unknown> // static config, published as ConnectionMeta.configs.ui
19+
configs?: () => Record<string, unknown> // static config, published as ConnectionMeta.configs.ui
2020
}
2121
```
2222

@@ -25,12 +25,12 @@ prebuilt assets: the viewer SPA is built with relative asset paths, and the
2525
embedded entry is one self-contained ES module that mounts your dock into any
2626
host page.
2727

28-
`settings` publishes whatever you return verbatim as
28+
`configs` publishes whatever you return verbatim as
2929
`ConnectionMeta.configs.ui` — the reference UI's `createUi({ branding })` uses
3030
it to deliver `{ branding }`, read by the dock from the one connection
3131
handshake it already performs, rather than a separate fetched file. The hub
3232
never interprets this object; it's a policy-free pass-through to your own
33-
client code. It's the read-only counterpart to `assets`: reach for `settings`
33+
client code. It's the read-only counterpart to `assets`: reach for `configs`
3434
for small, structured, boot-time config, and `assets` for arbitrary served
3535
files.
3636

docs/guide/devframe-definition.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ interface DevframeNodeContext {
113113
diagnostics: DevframeDiagnosticsHost
114114
agent: DevframeAgentHost // experimental
115115
services: DevframeServicesHost // typed cross-plugin service registry
116+
configs: DevframeConfigsHost // contribute to this context's own ConnectionMeta.configs
116117

117118
scope: (id) => DevframeScopedNodeContext // namespaced view (preferred)
118119
}
@@ -130,6 +131,22 @@ ctx.services.whenAvailable('my-plugin:sources', (sources) => {
130131
})
131132
```
132133

134+
### Static connection configs
135+
136+
`ctx.configs` builds up `ConnectionMeta.configs` — static, boot-time data delivered once through the connection handshake every client already performs, and never mutated again. Contrast it with `ctx.scope(id).settings`, which is mutable and synced bidirectionally over shared-state RPC for the life of the session.
137+
138+
```ts
139+
declare module 'devframe/types' {
140+
interface DevframeConnectionConfigsRegistry {
141+
'my-plugin': { featureFlag: boolean }
142+
}
143+
}
144+
145+
ctx.configs.contribute('my-plugin', () => ({ featureFlag: true }))
146+
```
147+
148+
`updater` receives whatever's been contributed to that key so far (or `undefined` on the first contribution), so multiple contributors sharing a key — a hub aggregating each installed devframe's own preference, for example — own their own merge semantics (overwrite, shallow-merge a record, …) rather than the host imposing one.
149+
133150
### Storage scopes
134151

135152
`ctx.host.getStorageDir(scope)` places persisted state in one of three classes:
@@ -152,6 +169,7 @@ Each devframe-level host has a dedicated page:
152169
- [Shared State](./shared-state)`ctx.rpc.sharedState`
153170
- [Diagnostics](./diagnostics)`ctx.diagnostics`
154171
- [Agent-Native](./agent-native)`ctx.agent`
172+
- [Cross-Plugin Services](./services)`ctx.services`
155173

156174
## Browser setup
157175

docs/guide/hub-initiate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ interface DevframeHubUi {
5757
viewer?: { distDir: string } // a standalone SPA served at the namespace root
5858
embedded?: { entry: string } // a prebuilt bootstrap served at <base>embedded.js
5959
assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
60-
settings?: () => Record<string, unknown> // static config, published as ConnectionMeta.configs.ui
60+
configs?: () => Record<string, unknown> // static config, published as ConnectionMeta.configs.ui
6161
}
6262
```
6363

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { DevframeConfigsHostImpl } from '../host-configs'
3+
4+
describe('devframeConfigsHost', () => {
5+
it('resolves empty before any contribution', () => {
6+
const configs = new DevframeConfigsHostImpl()
7+
expect(configs.resolve()).toEqual({})
8+
})
9+
10+
it('passes undefined to the updater on the first contribution for a key', () => {
11+
// The registry is empty in the core package (augmented by consumers like
12+
// `@devframes/hub`), so the test drives `contribute`/`resolve` untyped.
13+
const configs = new DevframeConfigsHostImpl() as any
14+
configs.contribute('dock', (current: any) => {
15+
expect(current).toBeUndefined()
16+
return { maxVisibleItems: 4 }
17+
})
18+
expect(configs.resolve()).toEqual({ dock: { maxVisibleItems: 4 } })
19+
})
20+
21+
it('threads the current value into each subsequent contribution for the same key', () => {
22+
const configs = new DevframeConfigsHostImpl() as any
23+
configs.contribute('dock', () => ({ categoryOrder: { app: -40 } }))
24+
configs.contribute('dock', (current: any) => ({
25+
categoryOrder: { ...current.categoryOrder, web: 300 },
26+
}))
27+
expect(configs.resolve()).toEqual({ dock: { categoryOrder: { app: -40, web: 300 } } })
28+
})
29+
30+
it('keeps contributions to different keys independent', () => {
31+
const configs = new DevframeConfigsHostImpl() as any
32+
configs.contribute('dock', () => ({ maxVisibleItems: 4 }))
33+
configs.contribute('ui', () => ({ branding: { productName: 'Test' } }))
34+
expect(configs.resolve()).toEqual({
35+
dock: { maxVisibleItems: 4 },
36+
ui: { branding: { productName: 'Test' } },
37+
})
38+
})
39+
})

packages/devframe/src/node/context.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DevframeHost, DevframeNodeContext, DevframeScopedNodeContext } fro
33
import { diagnostics as rpcDiagnostics } from '../rpc/diagnostics'
44
import { diagnostics as devframeDiagnostics } from './diagnostics'
55
import { DevframeAgentHost } from './host-agent'
6+
import { DevframeConfigsHostImpl } from './host-configs'
67
import { DevframeDiagnosticsHost } from './host-diagnostics'
78
import { RpcFunctionsHostImpl } from './host-functions'
89
import { DevframeServicesHostImpl } from './host-services'
@@ -44,6 +45,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom
4445
diagnostics: undefined!,
4546
agent: undefined!,
4647
services: undefined!,
48+
configs: undefined!,
4749
scope: undefined!,
4850
} as unknown as DevframeNodeContext
4951

@@ -54,6 +56,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom
5456
context.views = viewsHost
5557
context.diagnostics = diagnosticsHost
5658
context.services = new DevframeServicesHostImpl()
59+
context.configs = new DevframeConfigsHostImpl()
5760

5861
// Agent host must be constructed after `rpcHost` so it can subscribe
5962
// to `onChanged` — it auto-discovers RPC functions flagged with
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { DevframeConfigsHost, DevframeConnectionConfigsRegistry } from 'devframe/types'
2+
3+
/**
4+
* Backs `ctx.configs` (see `types/context.ts` for the contract). Values
5+
* accumulate in memory for the life of the context — `resolve()` is read
6+
* once, after every contributor has run, to build the served
7+
* `ConnectionMeta.configs`.
8+
*/
9+
export class DevframeConfigsHostImpl implements DevframeConfigsHost {
10+
private readonly values: Partial<DevframeConnectionConfigsRegistry> = {}
11+
12+
contribute<K extends keyof DevframeConnectionConfigsRegistry>(
13+
key: K,
14+
updater: (current: DevframeConnectionConfigsRegistry[K] | undefined) => DevframeConnectionConfigsRegistry[K],
15+
): void {
16+
this.values[key] = updater(this.values[key])
17+
}
18+
19+
resolve(): Partial<DevframeConnectionConfigsRegistry> {
20+
return this.values
21+
}
22+
}

packages/devframe/src/node/instance-shell.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,15 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
589589
...(result.mcp ? { mcp: result.mcp } : {}),
590590
}
591591

592+
// Every contribution (`ctx.configs.contribute(...)`) made during
593+
// `options.init(api)` — e.g. a hub aggregating each installed
594+
// devframe's own dock-bar preferences — is in by now; bake the
595+
// resolved configs into the meta `options.mount` (and every host that
596+
// re-serves this same meta at another base) publishes.
597+
const configs = ctx.configs.resolve()
598+
if (Object.keys(configs).length > 0)
599+
meta.configs = configs
600+
592601
await options.mount?.(ctx, meta, api)
593602

594603
// A pinned origin means the banner and registry record needn't wait for a

packages/devframe/src/types/context.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export interface DevframeNodeContext {
6464
* absorb setup-order differences between provider and consumer.
6565
*/
6666
services: DevframeServicesHost
67+
/**
68+
* The API for contributing to this context's own {@link ConnectionMeta.configs}
69+
* — static, boot-time config a host publishes once and never mutates
70+
* again. See {@link DevframeConfigsHost}.
71+
*/
72+
configs: DevframeConfigsHost
6773
/**
6874
* Create a namespace-scoped view of this context. The returned
6975
* `ctx.scope('my-plugin')` auto-namespaces every RPC id, shared-state
@@ -238,3 +244,30 @@ export interface ConnectionMeta {
238244
* augments it with `dock`; `@devframes/hub-ui` augments it with `ui`.
239245
*/
240246
export interface DevframeConnectionConfigsRegistry {}
247+
248+
/**
249+
* The API for building up {@link ConnectionMeta.configs} — every registered
250+
* key merges from whatever contributes to it (a plugin's own `setup(ctx)`,
251+
* a hub aggregating across every installed devframe, …) into the one
252+
* document a host publishes once and serves for the life of the server.
253+
*
254+
* `updater` receives whatever's been contributed to `key` so far (or
255+
* `undefined` on the first contribution) and returns the new value — the
256+
* contributor owns its own merge semantics (overwrite, shallow-merge a
257+
* record, …), not this host.
258+
*
259+
* ```ts
260+
* ctx.configs.contribute('dock', (current = {}) => ({
261+
* ...current,
262+
* categoryOrder: { ...current.categoryOrder, ...myCategoryOrder },
263+
* }))
264+
* ```
265+
*/
266+
export interface DevframeConfigsHost {
267+
contribute: <K extends keyof DevframeConnectionConfigsRegistry>(
268+
key: K,
269+
updater: (current: DevframeConnectionConfigsRegistry[K] | undefined) => DevframeConnectionConfigsRegistry[K],
270+
) => void
271+
/** Everything contributed so far — what a host publishes as `ConnectionMeta.configs`. */
272+
resolve: () => Partial<DevframeConnectionConfigsRegistry>
273+
}

packages/hub-ui/src/client/embedded/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,10 @@ async function mountDock(): Promise<void> {
5959
)
6060

6161
// Resolve branding before the dock exists so the primary color and logo are
62-
// in place on the first paint. Read from `ConnectionMeta.configs.ui.branding`
63-
// (already fetched above), then overridden by any host-page channel.
62+
// in place on the first paint. Read from `ConnectionMeta.configs.ui.branding`,
63+
// carried by the connection we just established above.
6464
const { resolveBranding, applyPrimaryColor } = await import('../state/branding')
65-
const branding = resolveBranding({
66-
mode: 'embedded',
67-
branding: rpc.connectionMeta.configs?.ui?.branding,
68-
})
65+
const branding = resolveBranding(rpc.connectionMeta.configs?.ui?.branding)
6966

7067
const { createDocksContext } = await import('../state/context')
7168
const context = await createDocksContext('embedded', rpc, state)

packages/hub-ui/src/client/standalone/main.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@ async function main(): Promise<void> {
3434
// `ConnectionMeta.configs.ui.branding`, carried by the connection we just
3535
// established above.
3636
const { resolveBranding, applyPrimaryColor, applyDocumentHead } = await import('../state/branding')
37-
const branding = resolveBranding({
38-
mode: 'standalone',
39-
branding: rpc.connectionMeta.configs?.ui?.branding,
40-
})
37+
const branding = resolveBranding(rpc.connectionMeta.configs?.ui?.branding)
4138
applyDocumentHead(document, branding)
4239

4340
const { createDocksContext } = await import('../state/context')

0 commit comments

Comments
 (0)