Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type { DevframeRpcClient } from 'devframe/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { DevframeDockEntry } from '../../types/docks'
import type { DevframeClientHost, DevframeClientHostOptions } from '../host'
import { createEventEmitter } from 'devframe/utils/events'
import { describe, expect, it, vi } from 'vitest'
import { getDevframeClientContext } from '../context'
import { createDevframeClientHost } from '../host'

/** Boot a host that is expected to actually boot (narrow away the skipped arm). */
async function boot(options: DevframeClientHostOptions): Promise<DevframeClientHost> {
const host = await createDevframeClientHost(options)
if (host.skipped)
throw new Error('expected the client host to boot, but it skipped')
return host
}

interface StubSharedState<T> extends SharedState<T> {
/** Replace the state wholesale and emit `updated` (simulates a server patch). */
push: (next: T) => void
Expand Down Expand Up @@ -43,6 +52,8 @@ function createStubRpc() {
},
call: async (...args: any[]) => {
calls.push(args)
if (args[0] === 'hub:messages:add')
return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] }
return `rpc:${args[0]}`
},
} as unknown as DevframeRpcClient
Expand All @@ -56,7 +67,7 @@ function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockE
describe('createDevframeClientHost', () => {
it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const host = await boot({ rpc })

expect(getDevframeClientContext()).toBe(host.context)
expect(host.context.clientType).toBe('standalone')
Expand All @@ -73,7 +84,7 @@ describe('createDevframeClientHost', () => {

it('reconciles dock entries from shared state and tracks per-entry state', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const host = await boot({ rpc })
const docksState = states.get('devframe:docks')!

docksState.push([iframeEntry('one'), iframeEntry('two')])
Expand All @@ -88,7 +99,7 @@ describe('createDevframeClientHost', () => {

it('switches entries with activation/deactivation events and when-context updates', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const host = await boot({ rpc })
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')])

const activated: string[] = []
Expand All @@ -115,7 +126,7 @@ describe('createDevframeClientHost', () => {

it('executes client commands locally and server commands over hub:commands:execute', async () => {
const { rpc, calls } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const host = await boot({ rpc })

const ran: any[] = []
const off = host.context.commands.register({
Expand All @@ -139,8 +150,8 @@ describe('createDevframeClientHost', () => {
})

it('imports a dock entry client script and hands it the script context', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const { rpc, states, calls } = createStubRpc()
const host = await boot({ rpc })

const received: any[] = []
;(globalThis as any).__DF_TEST_SCRIPT__ = (ctx: any) => received.push(ctx)
Expand All @@ -153,8 +164,62 @@ describe('createDevframeClientHost', () => {
const scriptCtx = received[0]
expect(scriptCtx.current.entryMeta.id).toBe('scripted')
expect(scriptCtx.rpc).toBe(rpc)
expect(typeof scriptCtx.messages.add).toBe('function')

// The messages client is scoped to the entry: `category` defaults to the
// entry id, and the doc'd per-level shortcuts delegate to add().
await scriptCtx.messages.add({ message: 'hello', level: 'info' })
expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hello', level: 'info', category: 'scripted' }])
await scriptCtx.messages.warn('careful', { category: 'a11y' })
expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'careful', level: 'warn', category: 'a11y' }])

delete (globalThis as any).__DF_TEST_SCRIPT__
host.dispose()
})

it('skips boot inside an iframe for embedded hosts, overridable via skipInIframe', async () => {
vi.stubGlobal('window', { self: {}, top: {} })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { rpc } = createStubRpc()
const skipped = await createDevframeClientHost({ rpc, clientType: 'embedded' })
expect(skipped.skipped).toBe(true)
expect(skipped.context).toBeUndefined()
expect(getDevframeClientContext()).toBeUndefined()
skipped.dispose()

// Standalone hosts (and an explicit opt-out) still boot inside iframes.
const standalone = await createDevframeClientHost({ rpc })
expect(standalone.skipped).toBe(false)
standalone.dispose()
const optOut = await createDevframeClientHost({ rpc, clientType: 'embedded', skipInIframe: false })
expect(optOut.skipped).toBe(false)
optOut.dispose()
}
finally {
vi.unstubAllGlobals()
warn.mockRestore()
}
})

it('warns when a second host replaces a published context; dispose unpublishes it', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { rpc } = createStubRpc()
const first = await boot({ rpc })
expect(warn).not.toHaveBeenCalled()

const second = await boot({ rpc })
expect(warn).toHaveBeenCalledOnce()
expect(getDevframeClientContext()).toBe(second.context)

// The first host no longer owns the published context — leave it alone.
first.dispose()
expect(getDevframeClientContext()).toBe(second.context)
second.dispose()
expect(getDevframeClientContext()).toBeUndefined()
}
finally {
warn.mockRestore()
}
})
})
45 changes: 45 additions & 0 deletions packages/hub/src/client/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { createMessagesClient } from '../messages'

function createStubRpc() {
const calls: any[][] = []
const rpc = {
call: async (...args: any[]) => {
calls.push(args)
if (args[0] === 'hub:messages:add')
return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] }
if (args[0] === 'hub:messages:update')
return { id: args[1], timestamp: 1, from: 'browser', ...args[2] }
return undefined
},
} as any
return { rpc, calls }
}

describe('createMessagesClient', () => {
it('merges defaults beneath the input; explicit input fields win', async () => {
const { rpc, calls } = createStubRpc()
const messages = createMessagesClient(rpc, { defaults: { category: 'my-entry' } })

await messages.add({ message: 'hello', level: 'info' })
expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hello', level: 'info', category: 'my-entry' }])

await messages.add({ message: 'custom', level: 'info', category: 'a11y' })
expect(calls.at(-1)?.[1].category).toBe('a11y')
})

it('provides per-level shortcuts that delegate to add()', async () => {
const { rpc, calls } = createStubRpc()
const messages = createMessagesClient(rpc)

const handle = await messages.info('hi')
expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hi', level: 'info' }])
expect(handle.id).toBe('msg-1')

await messages.error('boom', { notify: true })
expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'boom', level: 'error', notify: true }])

await handle.dismiss()
expect(calls.at(-1)).toEqual(['hub:messages:remove', 'msg-1'])
})
})
3 changes: 2 additions & 1 deletion packages/hub/src/client/client-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export interface DockClientScriptContext extends DocksContext {
*/
current: DockEntryState
/**
* Messages client scoped to this dock entry's source
* Messages client scoped to this dock entry — messages it adds default
* their `category` to the entry id, so the feed can attribute them.
*/
messages: DevframeMessagesClient
}
8 changes: 4 additions & 4 deletions packages/hub/src/client/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export function getDevframeClientContext(): DevframeClientContext | undefined {
}

/**
* Publish the global Devframe client context. Called by
* {@link import('./host').createDevframeClientHost}; a dock client script or a
* viewer reads it back with {@link getDevframeClientContext}.
* Publish the global Devframe client context (or clear it with `undefined`).
* Called by {@link import('./host').createDevframeClientHost}; a dock client
* script or a viewer reads it back with {@link getDevframeClientContext}.
*/
export function setDevframeClientContext(ctx: DevframeClientContext): void {
export function setDevframeClientContext(ctx: DevframeClientContext | undefined): void {
(globalThis as any)[CLIENT_CONTEXT_KEY] = ctx
}

Expand Down
51 changes: 48 additions & 3 deletions packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {
import { connectDevframe } from 'devframe/client'
import { createEventEmitter } from 'devframe/utils/events'
import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS } from '../constants'
import { setDevframeClientContext } from './context'
import { getDevframeClientContext, setDevframeClientContext } from './context'
import { createMessagesClient } from './messages'

const DOCKS_STATE_KEY = 'devframe:docks'
Expand All @@ -52,15 +52,34 @@ export interface DevframeClientHostOptions {
* `custom-render`, and iframe `clientScript`). Default `true`.
*/
loadClientScripts?: boolean
/**
* Skip booting when the page runs inside an iframe, so a nested frame never
* publishes a second context or re-runs every dock client script. Defaults
* to `true` for `'embedded'` hosts (dock iframe panels and nested copies of
* the app skip themselves) and `false` for `'standalone'` hosts (a hub UI
* page stays bootable when intentionally iframed).
*/
skipInIframe?: boolean
}

export interface DevframeClientHost {
/** The host booted and published its context. */
skipped: false
/** The assembled, globally-registered client host context. */
context: DevframeClientContext
/** Tear down listeners and stop tracking newly-registered client scripts. */
dispose: () => void
}

export interface DevframeClientHostSkipped {
/** Boot was skipped — the page runs inside an iframe (see `skipInIframe`). */
skipped: true
context: undefined
dispose: () => void
}

export type DevframeClientHostResult = DevframeClientHost | DevframeClientHostSkipped

/**
* Boot the framework-level client host: connect RPC, assemble the full
* {@link DevframeClientContext} (panel, docks, commands, when) from the hub's
Expand All @@ -73,8 +92,13 @@ export interface DevframeClientHost {
*/
export async function createDevframeClientHost(
options: DevframeClientHostOptions = {},
): Promise<DevframeClientHost> {
): Promise<DevframeClientHostResult> {
const clientType: DockClientType = options.clientType ?? 'standalone'
if ((options.skipInIframe ?? clientType === 'embedded') && isInsideIframe()) {
console.warn('[@devframes/hub] Skipping client host in iframe')
return { skipped: true, context: undefined, dispose: () => {} }
}

const rpc = options.rpc ?? await connectDevframe(options.connect)

const [docksState, commandsState, settings] = await Promise.all([
Expand Down Expand Up @@ -115,19 +139,27 @@ export async function createDevframeClientHost(
reconcileEntries()
disposers.push(docksState.on('updated', reconcileEntries))

if (getDevframeClientContext()) {
console.warn(
'[@devframes/hub] A client host context is already published on this page — replacing it. '
+ 'Boot createDevframeClientHost() once per page (e.g. HTML injection combined with a manual import boots it twice).',
)
}
setDevframeClientContext(context)

const messages = createMessagesClient(rpc)
const loadedScripts = new Set<string>()
if (options.loadClientScripts ?? true) {
loadClientScripts()
disposers.push(docksState.on('updated', loadClientScripts))
}

return {
skipped: false,
context,
dispose() {
for (const off of disposers.splice(0)) off()
if (getDevframeClientContext() === context)
setDevframeClientContext(undefined)
},
}

Expand Down Expand Up @@ -277,6 +309,9 @@ export async function createDevframeClientHost(
const current = entryToStateMap.get(entryId)
if (!current)
return
// Scope the messages client to this entry: its messages default their
// `category` to the entry id, so the feed can attribute and group them.
const messages = createMessagesClient(rpc, { defaults: { category: entryId } })
const scriptContext: DockClientScriptContext = { ...context, current, messages }
await fn(scriptContext)
}
Expand All @@ -289,6 +324,16 @@ export async function createDevframeClientHost(

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

function isInsideIframe(): boolean {
try {
return typeof window !== 'undefined' && window.self !== window.top
}
catch {
// Cross-origin access threw — we are definitely inside a foreign frame.
return true
}
}

function createPanelContext(clientType: DockClientType): DocksPanelContext {
const store: DocksPanelContext['store'] = {
mode: 'edge',
Expand Down
33 changes: 28 additions & 5 deletions packages/hub/src/client/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,27 @@ import type {
DevframeMessageEntry,
DevframeMessageEntryInput,
DevframeMessageHandle,
DevframeMessageLevel,
DevframeMessagesClient,
DevframeMessageShortcutInput,
} from '../types/messages'

export interface MessagesClientOptions {
/**
* Default fields merged beneath every `add()` input — the client host passes
* `{ category: entry.id }` to scope a dock client script's messages to its
* entry. Fields set on the input itself win.
*/
defaults?: Partial<DevframeMessageEntryInput>
}

/**
* Build a browser-side {@link DevframeMessagesClient} that writes into the
* hub's messages subsystem over the `hub:messages:*` built-in RPCs. The handle
* returned by `add` proxies `update`/`dismiss` back through the same RPCs, so a
* dock client script reports into the very feed the server writes to.
*/
export function createMessagesClient(rpc: DevframeRpcClient): DevframeMessagesClient {
export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesClientOptions = {}): DevframeMessagesClient {
// The `hub:messages:*` ids aren't in the statically-typed server map.
const call = rpc.call as (name: string, ...args: any[]) => Promise<any>

Expand All @@ -35,12 +46,24 @@ export function createMessagesClient(rpc: DevframeRpcClient): DevframeMessagesCl
}
}

async function add(input: DevframeMessageEntryInput): Promise<DevframeMessageHandle> {
const entry = await call('hub:messages:add', { ...options.defaults, ...input }) as DevframeMessageEntry
return makeHandle(entry)
}

function levelShortcut(level: DevframeMessageLevel) {
return (message: string, extra?: DevframeMessageShortcutInput) =>
add({ ...extra, message, level })
}

return {
async add(input: DevframeMessageEntryInput) {
const entry = await call('hub:messages:add', input) as DevframeMessageEntry
return makeHandle(entry)
},
add,
remove: id => call('hub:messages:remove', id) as Promise<void>,
clear: () => call('hub:messages:clear') as Promise<void>,
info: levelShortcut('info'),
warn: levelShortcut('warn'),
error: levelShortcut('error'),
success: levelShortcut('success'),
debug: levelShortcut('debug'),
}
}
Loading
Loading