From 6591da997fd35f81bfa1dc015ebf2d5e35b41c77 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 14 Sep 2026 01:57:14 +0200 Subject: [PATCH 1/4] fix(hub-ui): initialize dock page scripts before activation --- docs/content/1.guide/17.client-context.md | 6 +- docs/content/8.references/6.hub-api.md | 4 +- packages/devframe/src/types/devframe.ts | 5 + .../state/client-script.integration.test.ts | 168 +++++++++++++++++- .../hub-ui/src/client/state/context.test.ts | 3 +- packages/hub-ui/src/client/state/context.ts | 51 ++++-- .../hub-ui/src/client/state/setup-script.ts | 65 +++---- .../hub/src/client/__tests__/host.test.ts | 72 +++++++- packages/hub/src/client/host.ts | 75 ++++++-- packages/hub/src/types/docks.ts | 11 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 3 +- .../tsnapi/devframe/index.snapshot.d.ts | 1 + 12 files changed, 396 insertions(+), 68 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 8554feaac..7914cff6a 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -67,14 +67,16 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren ## Dock client scripts -A client script is a `ClientScriptEntry`: `{ importFrom, importName? }` (`importName` defaults `'default'`). The field varies by entry kind: an `action` entry's `action` runs when the dock button is activated, a `custom-render` entry's `renderer` renders its panel, and an `iframe` entry's optional `clientScript` runs alongside the iframe panel inside the host page ([Hub API reference](/references/hub-api#dock-client-script-fields)). +A client script is a `ClientScriptEntry`: `{ importFrom, importName?, eager? }`. `importName` defaults to `'default'` and `eager` defaults to `false`. Every dock entry can carry a page-level `clientScript`, including JSON-render entries. By default, it runs inside the host page when the dock entry is first activated, before its activation script. An `action` entry also runs its `action` on each activation, while a `custom-render` entry initializes its `renderer` after selection so it can mount into the panel. + +Set `eager: true` on a descriptor to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands. Page setup and activation scripts initialize independently, even when they import the same export. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)). The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras: - **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). - **`messages`**: an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`). -A failed import retries on the next dock update. +Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock, script role and import descriptor. Action clicks always execute again. ### Shipping a client script diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index bb1ee1bfb..cee88dd31 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -131,7 +131,9 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru |---|---|---| | `action` | `action` | when the dock button is activated | | `custom-render` | `renderer` | to render the entry's panel | -| `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page | +| Every user dock entry | `clientScript` (optional) | inside the host page on first activation, before the activation script | + +`ClientScriptEntry.eager` defaults to `false`. Set it to `true` to initialize that script after RPC trust, before dock activation. Page setup and activation scripts have separate caches; action clicks execute on every activation. ## Frame-nav messages diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 6e39f18d5..cb8150b0e 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -329,6 +329,11 @@ export interface DevframeDockDefaults { * host wiring; a URL or bare specifier passes through untouched. */ clientScript?: { + /** + * Initialize after RPC trust without waiting for dock activation. + * @default false + */ + eager?: boolean /** An absolute filesystem path, a served URL, or a bare npm specifier. */ importFrom: string /** diff --git a/packages/hub-ui/src/client/state/client-script.integration.test.ts b/packages/hub-ui/src/client/state/client-script.integration.test.ts index 9c60bd1e9..48421488a 100644 --- a/packages/hub-ui/src/client/state/client-script.integration.test.ts +++ b/packages/hub-ui/src/client/state/client-script.integration.test.ts @@ -1,6 +1,8 @@ import type { DevframeDockEntry } from '@devframes/hub' import type { DevframeRpcClient } from '@devframes/hub/client' +import type {} from '@devframes/json-render/hub' import type { SharedState } from 'devframe/utils/shared-state' +import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' import { createSharedState } from 'devframe/utils/shared-state' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -42,7 +44,7 @@ function createStubRpc() { declare global { // eslint-disable-next-line vars-on-top -- test hook called by the dynamically imported client module - var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void) | undefined + var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void | Promise) | undefined } afterEach(() => { @@ -52,6 +54,7 @@ afterEach(() => { describe('dock client scripts', () => { it('retries setup on a later activation after it fails', async () => { + expect.assertions(3) vi.spyOn(console, 'error').mockImplementation(() => {}) let attempts = 0 globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { @@ -63,11 +66,10 @@ describe('dock client scripts', () => { const context = await createDocksContext('embedded', rpc) const entry = { id: 'retry-client-script', - type: 'iframe', + type: 'custom-render', title: 'Retry client script', icon: 'ph:play', - url: '/retry', - clientScript: { + renderer: { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()', }, } satisfies DevframeDockEntry @@ -81,3 +83,161 @@ describe('dock client scripts', () => { expect(attempts).toBe(2) }) }) + +it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock activation, once per RPC client', async (type) => { + expect.assertions(3) + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { + attempts++ + } + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const clientScript = { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } + const entry = { id: `background-${type}`, type, title: 'Background page script', icon: 'ph:browser', url: '/fixture', view: { stateKey: 'fixture:view' }, clientScript } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([entry]) + await expect.poll(() => attempts).toBe(1) + expect(context.docks.selectedId).toBeNull() + await context.docks.switchEntry(entry.id) + expect(attempts).toBe(1) +}) + +it('waits for trust and keeps the same dock script bound separately to each RPC client', async () => { + expect.assertions(4) + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { + attempts++ + } + const first = createStubRpc() + const second = createStubRpc() + Object.assign(first.rpc, { isTrusted: false }) + await createDocksContext('embedded', first.rpc) + await createDocksContext('embedded', second.rpc) + const entry = { + id: 'per-rpc-page-script', + type: 'iframe', + title: 'Page commands', + icon: 'ph:browser', + url: '/fixture', + clientScript: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }, + } satisfies DevframeDockEntry + first.sharedStates.get('devframe:docks')!.push([entry]) + await nextTick() + expect(attempts).toBe(0) + second.sharedStates.get('devframe:docks')!.push([entry]) + await expect.poll(() => attempts).toBe(1) + Object.assign(first.rpc, { isTrusted: true }) + first.rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) + await expect.poll(() => attempts).toBe(2) + first.sharedStates.get('devframe:docks')!.push([{ ...entry }]) + second.sharedStates.get('devframe:docks')!.push([{ ...entry }]) + await nextTick() + expect(attempts).toBe(2) +}) + +it('does not invoke action docks while initializing page scripts', async () => { + expect.assertions(2) + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { + attempts++ + } + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const entry = { + id: 'explicit-action-script', + type: 'action', + title: 'Explicit action', + icon: 'ph:play', + action: { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }, + } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([entry]) + await nextTick() + expect(attempts).toBe(0) + await context.docks.switchEntry(entry.id) + expect(attempts).toBe(1) +}) + +it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', async (eager) => { + expect.assertions(3) + const attempt = vi.fn() + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const entry = { + id: 'lazy-page', + type: 'iframe', + title: 'Lazy page', + icon: 'ph:browser', + url: '/fixture', + clientScript: { eager, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }, + } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([entry]) + await nextTick() + expect(attempt).not.toHaveBeenCalled() + await context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledOnce() + await context.docks.switchEntry(null) + await context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledOnce() +}) + +it.each(['action', 'custom-render'] as const)('keeps the %s activation independent of its eager page script', async (type) => { + expect.assertions(5) + const attempt = vi.fn() + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } + const entry = { + id: `two-scripts-${type}`, + type, + title: 'Independent scripts', + icon: 'ph:play', + action: script, + renderer: script, + clientScript: { ...script, eager: true }, + } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([entry]) + await expect.poll(() => attempt.mock.calls.length).toBe(1) + expect(context.docks.selectedId).toBeNull() + await context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledTimes(2) + await context.docks.switchEntry(null) + await context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) + sharedStates.get('devframe:docks')!.push([{ ...entry }]) + await nextTick() + expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) +}) + +it('awaits an eager page setup before activation and retries it after failure', async () => { + expect.assertions(5) + vi.spyOn(console, 'error').mockImplementation(() => {}) + let complete!: () => void + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { + attempts++ + if (attempts === 1) + throw new Error('page setup failed') + if (attempts === 2) + return new Promise((resolve) => { complete = resolve }) + } + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } + const entry = { + id: 'retry-page-before-renderer', + type: 'custom-render', + title: 'Retry page', + icon: 'ph:play', + renderer: script, + clientScript: { ...script, eager: true }, + } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([entry]) + await expect.poll(() => attempts).toBe(1) + const activation = context.docks.switchEntry(entry.id) + await expect.poll(() => attempts).toBe(2) + expect(context.docks.selectedId).toBeNull() + complete() + await expect(activation).resolves.toBe(true) + expect(attempts).toBe(3) +}) diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index eed68cadd..e77313a4d 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -10,7 +10,8 @@ import { nextTick, ref } from 'vue' import { createDocksContext } from './context' import { executeSetupScript } from './setup-script' -vi.mock('./setup-script', () => ({ +vi.mock('./setup-script', async importOriginal => ({ + ...await importOriginal(), executeSetupScript: vi.fn(async () => {}), })) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 4ded43869..b4f317ae2 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -17,7 +17,7 @@ import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_ST import { createClientMessagesClient } from './messages-client' import { dockCommandId } from './palette' import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup' -import { executeSetupScript } from './setup-script' +import { dockScript, executeSetupScript } from './setup-script' const docksContextByRpc = new WeakMap() export async function createDocksContext( @@ -229,17 +229,41 @@ export async function createDocksContext( return null } - const runDockSetupScript = async (entry: DevframeDockEntry) => { - const hasScript = entry.type === 'action' || entry.type === 'custom-render' || (entry.type === 'iframe' && entry.clientScript) - if (!hasScript) - return - const messagesClient = createClientMessagesClient(rpc) - const scriptContext: DockClientScriptContext = reactive({ + function scriptContext(entry: DevframeDockEntry): DockClientScriptContext { + return reactive({ ...toRefs(docksContext) as any, current: dockEntryStateMap.get(entry.id)!, - messages: messagesClient, + messages: createClientMessagesClient(rpc), }) - await executeSetupScript(entry, scriptContext) + } + + async function runPageScript(entry: DevframeDockEntry): Promise { + if (entry.type === '~builtin' || !entry.clientScript) + return + await executeSetupScript(entry, scriptContext(entry), 'clientScript') + } + + async function runActivationScript(entry: DevframeDockEntry): Promise { + if (entry.type === 'action') + await executeSetupScript(entry, scriptContext(entry), 'action') + else if (entry.type === 'custom-render') + await executeSetupScript(entry, scriptContext(entry), 'renderer') + } + + /** Only explicitly eager descriptors run before activation, after the RPC connection is trusted. */ + function startPageScripts(): void { + if (!rpc.isTrusted) + return + for (const entry of entries.value) { + if (entry.type === '~builtin') + continue + for (const role of ['clientScript', 'action', 'renderer'] as const) { + if (!dockScript(entry, role)?.eager) + continue + /** Setup reports failures and allows the next activation or publication to retry. */ + void executeSetupScript(entry, scriptContext(entry), role, true).catch(() => {}) + } + } } // Remember selection redirects: a member tab as its frame's live tab, and a @@ -286,11 +310,15 @@ export async function createDocksContext( return false } + if (!rpc.isTrusted) + return false + await runPageScript(entry) + initialRestorePending.value = false selectedDockId.value = entry.id sessionStore.value.open = true - await runDockSetupScript(entry) + await runActivationScript(entry) rememberEntrySelection(entry) return true } @@ -707,6 +735,9 @@ export async function createDocksContext( ) void restoreAfterInitialization() + watch(entries, startPageScripts, { immediate: true }) + rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, startPageScripts) + docksContextByRpc.set(rpc, docksContext) return docksContext } diff --git a/packages/hub-ui/src/client/state/setup-script.ts b/packages/hub-ui/src/client/state/setup-script.ts index eb6642c2b..ec158953f 100644 --- a/packages/hub-ui/src/client/state/setup-script.ts +++ b/packages/hub-ui/src/client/state/setup-script.ts @@ -1,29 +1,24 @@ import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub' -import type { DockClientScriptContext } from '@devframes/hub/client' +import type { DevframeRpcClient, DockClientScriptContext } from '@devframes/hub/client' import { clientScriptFailureHint, resolveClientModuleSpecifier } from '@devframes/hub/client' -/** - * Resolve the {@link ClientScriptEntry} a dock entry carries: an `action`'s - * `action`, a `custom-render`'s `renderer`, or an iframe's `clientScript`. - */ -function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined { - switch (entry.type) { - case 'action': - return entry.action - case 'custom-render': - return entry.renderer - case 'iframe': - return entry.clientScript - default: - return undefined - } +export type DockScriptRole = 'clientScript' | 'action' | 'renderer' + +/** Page setup and activation scripts have independent initialization lifetimes. */ +export function dockScript(entry: DevframeDockUserEntry, role: DockScriptRole): ClientScriptEntry | undefined { + if (role === 'clientScript') + return entry.clientScript + if (role === 'action' && entry.type === 'action') + return entry.action + if (role === 'renderer' && entry.type === 'custom-render') + return entry.renderer } async function _executeSetupScript( entry: DevframeDockUserEntry, context: DockClientScriptContext, + script: ClientScriptEntry | undefined, ): Promise { - const script = clientScriptOf(entry) if (!script?.importFrom) throw new Error(`[@devframes/hub-ui] Dock entry "${entry.id}" carries no client script to run`) // A bare specifier resolves through the host-advertised template; URL @@ -53,23 +48,33 @@ async function _executeSetupScript( throw error } } -const _setupPromises = new Map>() +const setupPromisesByRpc = new WeakMap>>() + +/** Cache setup per RPC connection, dock and role; explicit action clicks always run again. */ export function executeSetupScript( entry: DevframeDockUserEntry, context: DockClientScriptContext, + role: DockScriptRole, + cache = role !== 'action', ): Promise { - // Actions should re-execute on every click; only cache non-action scripts - if (entry.type !== 'action' && _setupPromises.has(entry.id)) - return _setupPromises.get(entry.id)! - const promise = _executeSetupScript(entry, context) - if (entry.type !== 'action') { - _setupPromises.set(entry.id, promise) - promise.catch(() => { - // A failed setup must not poison this entry permanently. The caller still - // receives the rejection, while a later activation or update may retry. - if (_setupPromises.get(entry.id) === promise) - _setupPromises.delete(entry.id) - }) + const script = dockScript(entry, role) + let setupPromises = setupPromisesByRpc.get(context.rpc) + if (!setupPromises) { + setupPromises = new Map() + setupPromisesByRpc.set(context.rpc, setupPromises) } + const key = JSON.stringify([entry.id, role, script?.importFrom, script?.importName ?? 'default']) + const existing = setupPromises.get(key) + if (cache && existing) + return existing + const promise = _executeSetupScript(entry, context, script) + if (!cache) + return promise + setupPromises.set(key, promise) + void promise.catch(() => { + /** Failed setup can retry on activation or a later dock publication. */ + if (setupPromises.get(key) === promise) + setupPromises.delete(key) + }) return promise } diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 7badd770f..b376b9da1 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -1,6 +1,7 @@ import type { DevframeRpcClient } from 'devframe/client' import type { SharedState } from 'devframe/utils/shared-state' import type { DevframeDockEntry, DevframeDockPanelState } from '../../types/docks' +import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' import { HUB_EVENTS } from '../../events' @@ -38,6 +39,8 @@ function createStubRpc() { const states = new Map>() const definitions = new Map any }>() const partial: DeepPartial = { + isTrusted: true, + events: createEventEmitter(), sharedState: { async get(key: string, options?: { initialValue?: any }) { if (!states.has(key)) @@ -301,7 +304,7 @@ describe('createDevframeClientRuntime', () => { const received: any[] = [] ;(globalThis as any).__DF_TEST_CLIENT_DOCK__ = (ctx: any) => received.push(ctx) const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_CLIENT_DOCK__(ctx)` - host.context.docks.register(iframeEntry('local', { clientScript: { importFrom: dataUrl } })) + host.context.docks.register(iframeEntry('local', { clientScript: { eager: true, importFrom: dataUrl } })) await vi.waitFor(() => expect(received).toHaveLength(1)) expect(received[0].current.entryMeta.id).toBe('local') @@ -380,7 +383,7 @@ describe('createDevframeClientRuntime', () => { ;(globalThis as any).__DF_TEST_SCRIPT__ = (ctx: any) => received.push(ctx) const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_SCRIPT__(ctx)` states.get('devframe:docks')!.push([ - iframeEntry('scripted', { clientScript: { importFrom: dataUrl } }), + iframeEntry('scripted', { clientScript: { eager: true, importFrom: dataUrl } }), ]) await vi.waitFor(() => expect(received).toHaveLength(1)) @@ -421,3 +424,68 @@ describe('createDevframeClientRuntime', () => { } }) }) + +it.each(['action', 'custom-render'] as const)('loads independent page and activation scripts in the headless %s runtime', async (type) => { + expect.assertions(5) + const { rpc, states } = createStubRpc() + const runtime = await createDevframeClientRuntime({ rpc }) + const attempt = vi.fn() + const fixture = globalThis as typeof globalThis & { __DF_ROLE_TEST__?: () => void } + fixture.__DF_ROLE_TEST__ = attempt + const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DF_ROLE_TEST__()' } + const entry = { + id: 'independent-scripts', + type, + title: 'Independent scripts', + icon: 'ph:play', + clientScript: { ...script, eager: true }, + action: script, + renderer: script, + } as DevframeDockEntry + try { + states.get('devframe:docks')!.push([entry]) + await expect.poll(() => attempt.mock.calls.length).toBe(1) + expect(runtime.context.docks.selectedId).toBeNull() + await runtime.context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledTimes(2) + await runtime.context.docks.switchEntry(null) + await runtime.context.docks.switchEntry(entry.id) + expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) + states.get('devframe:docks')!.push([{ ...entry }]) + expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) + } + finally { + runtime.dispose() + delete fixture.__DF_ROLE_TEST__ + } +}) + +it('waits for trust for eager setup and activation for lazy setup in the headless runtime', async () => { + expect.assertions(5) + const { rpc, states } = createStubRpc() + Object.assign(rpc, { isTrusted: false }) + const runtime = await createDevframeClientRuntime({ rpc }) + const attempt = vi.fn() + const fixture = globalThis as typeof globalThis & { __DF_LAZY_TEST__?: () => void } + fixture.__DF_LAZY_TEST__ = attempt + const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DF_LAZY_TEST__()' } + const eager = iframeEntry('eager', { clientScript: { ...script, eager: true } }) + const lazy = iframeEntry('lazy', { clientScript: script }) + try { + states.get('devframe:docks')!.push([eager, lazy]) + expect(attempt).not.toHaveBeenCalled() + await expect(runtime.context.docks.switchEntry('lazy')).resolves.toBe(false) + Object.assign(rpc, { isTrusted: true }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) + await expect.poll(() => attempt.mock.calls.length).toBe(1) + await runtime.context.docks.switchEntry('lazy') + expect(attempt).toHaveBeenCalledTimes(2) + await runtime.context.docks.switchEntry(null) + await runtime.context.docks.switchEntry('lazy') + expect(attempt).toHaveBeenCalledTimes(2) + } + finally { + runtime.dispose() + delete fixture.__DF_LAZY_TEST__ + } +}) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 3243ac3d5..a2895b890 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -27,6 +27,7 @@ import type { } from './docks' import type { DockRenderer, DockRendererManifest, DockRenderersContext } from './renderers' import { connectDevframe } from 'devframe/client' +import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' import { clientScriptFailureHint, resolveClientModuleSpecifier } from '../client-modules' import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '../constants' @@ -235,15 +236,18 @@ export async function createDevframeClientRuntime( } setDevframeClientContext(context) - const loadedScripts = new Set() + let disposed = false + const loadedScripts = new Map>() if (loadScriptsEnabled) { loadClientScripts() disposers.push(docksState.on('updated', loadClientScripts)) + disposers.push(rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, loadClientScripts)) } return { context, dispose() { + disposed = true for (const off of disposers.splice(0)) off() for (const disposeAdapter of frameNavAdapters.values()) disposeAdapter() frameNavAdapters.clear() @@ -417,13 +421,34 @@ export async function createDevframeClientRuntime( return ctx } + async function preparePageScript(entry: DevframeDockEntry): Promise { + if (!rpc.isTrusted) + return false + if (entry.type !== '~builtin' && entry.clientScript) + await setupClientScript(entry.id, entry.clientScript, 'clientScript') + return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry + } + + async function runActivationScript(entry: DevframeDockEntry): Promise { + if (entry.type === 'action') + await setupClientScript(entry.id, entry.action, 'action', false) + else if (entry.type === 'custom-render') + await setupClientScript(entry.id, entry.renderer, 'renderer') + } + async function switchEntry(id?: string | null): Promise { const next = id ?? null - if (next === selectedId) + if (next === selectedId && entryToStateMap.get(next ?? '')?.entryMeta.type !== 'action') return false if (next !== null && !entryToStateMap.has(next)) return false + const entry = entryToStateMap.get(next ?? '')?.entryMeta + if (entry && loadScriptsEnabled && !rpc.isTrusted) + return false + if (entry?.type !== '~builtin' && entry?.clientScript && loadScriptsEnabled && !await preparePageScript(entry)) + return false + const previous = selectedId selectedId = next // Mirror onto the session context so a persisting host and the when-clause @@ -437,6 +462,8 @@ export async function createDevframeClientRuntime( entryToStateMap.get(previous)?.events.emit('entry:deactivated') if (next) entryToStateMap.get(next)?.events.emit('entry:activated') + if (entry && loadScriptsEnabled) + await runActivationScript(entry) return true } @@ -524,20 +551,42 @@ export async function createDevframeClientRuntime( // ── client scripts ─────────────────────────────────────────────────────── - function clientScriptOf(entry: DevframeDockEntry): ClientScriptEntry | undefined { - return (entry as any).action ?? (entry as any).renderer ?? (entry as any).clientScript - } - function loadClientScripts(): void { + if (disposed || !rpc.isTrusted) + return for (const entry of currentEntries()) { - const script = clientScriptOf(entry) - if (!script?.importFrom || loadedScripts.has(entry.id)) + if (entry.type === '~builtin') continue - loadedScripts.add(entry.id) - void runClientScript(entry.id, script) + startEagerScript(entry.id, entry.clientScript, 'clientScript') + if (entry.type === 'action') + startEagerScript(entry.id, entry.action, 'action') + else if (entry.type === 'custom-render') + startEagerScript(entry.id, entry.renderer, 'renderer') } } + function startEagerScript(entryId: string, script: ClientScriptEntry | undefined, role: string): void { + if (script?.eager) + void setupClientScript(entryId, script, role).catch(() => {}) + } + + /** Keep page and activation setup separate even when they import the same export. */ + function setupClientScript(entryId: string, script: ClientScriptEntry, role: string, cache = true): Promise { + const key = JSON.stringify([entryId, role, script.importFrom, script.importName ?? 'default']) + const existing = loadedScripts.get(key) + if (cache && existing) + return existing + const promise = runClientScript(entryId, script) + if (!cache) + return promise + loadedScripts.set(key, promise) + void promise.catch(() => { + if (loadedScripts.get(key) === promise) + loadedScripts.delete(key) + }) + return promise + } + async function runClientScript(entryId: string, script: ClientScriptEntry): Promise { // A bare specifier resolves through the explicit option, then the // host-advertised template; URL specifiers pass through untouched. (The @@ -554,9 +603,9 @@ export async function createDevframeClientRuntime( const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ specifier) const fn = mod[script.importName ?? 'default'] if (typeof fn !== 'function') - return + throw new Error(`[@devframes/hub] "${specifier}" exports no callable "${script.importName ?? 'default'}"`) const current = entryToStateMap.get(entryId) - if (!current) + if (!current || disposed || !rpc.isTrusted) 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. @@ -565,11 +614,11 @@ export async function createDevframeClientRuntime( await fn(scriptContext) } catch (error) { - loadedScripts.delete(entryId) console.error( `[@devframes/hub] failed to load client script for "${entryId}" from ${specifier}${clientScriptFailureHint(script.importFrom, specifier)}`, error, ) + throw error } } } diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index f7c36d413..c81378320 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -81,6 +81,8 @@ export type DevframeDockEntryIcon = string | { light: string, dark: string } export type DevframeDockBadgeVariant = 'default' | 'info' | 'success' | 'warning' | 'danger' export interface DevframeDockEntryBase { + /** Page script run in the host page when activated, or after trust when `eager: true`. */ + clientScript?: ClientScriptEntry id: string title: string icon: DevframeDockEntryIcon @@ -171,6 +173,11 @@ export interface DevframeDockEntryBase { } export interface ClientScriptEntry { + /** + * Initialize after trust without waiting for dock activation. + * @default false + */ + eager?: boolean /** * What to import: either a **URL the host serves** (a self-contained ES * module, e.g. `/@fs/` under Vite or a statically-mounted bundle @@ -231,10 +238,6 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { * share a `frameId` may live in one group, several groups, or none. */ frameId?: string - /** - * Optional client script to import into the iframe - */ - clientScript?: ClientScriptEntry /** * Soft-navigation target within a shared frame. Set on a **member** dock * (one of several docks sharing a {@link frameId}) to describe which internal diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 0c2878326..003b70bd2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -3,6 +3,7 @@ */ // #region Interfaces export interface ClientScriptEntry { + eager?: boolean; importFrom: string; importName?: string; } @@ -82,6 +83,7 @@ export interface DevframeDockActivation { params?: Record; } export interface DevframeDockEntryBase { + clientScript?: ClientScriptEntry; id: string; title: string; icon: DevframeDockEntryIcon; @@ -315,7 +317,6 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { openExternal?: boolean; }; frameId?: string; - clientScript?: ClientScriptEntry; navTarget?: NavTarget; subTabs?: FrameSubTabsConfig; remote?: boolean | RemoteDockOptions; diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 7dbdabed2..d4e53807e 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -171,6 +171,7 @@ export interface DevframeDockDefaults { badge?: string; groupId?: string; clientScript?: { + eager?: boolean; importFrom: string; importName?: string; }; From e2b7d82e1ed8fd0caf5da423a465e2b9f61db6f8 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 14 Sep 2026 13:41:54 +0200 Subject: [PATCH 2/4] test(hub): provide RPC events in renderer fixtures --- packages/hub/src/client/__tests__/renderers.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/hub/src/client/__tests__/renderers.test.ts b/packages/hub/src/client/__tests__/renderers.test.ts index 47840b167..5b40a28bf 100644 --- a/packages/hub/src/client/__tests__/renderers.test.ts +++ b/packages/hub/src/client/__tests__/renderers.test.ts @@ -33,6 +33,8 @@ function createStubSharedState(initial: T): StubSharedState { function createStubRpc() { const states = new Map>() const partial: DeepPartial = { + isTrusted: true, + events: createEventEmitter(), sharedState: { async get(key: string, options?: { initialValue?: any }) { if (!states.has(key)) From 51d4100497e4ea118b5935122c62740775e86ca6 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 14 Sep 2026 14:54:39 +0200 Subject: [PATCH 3/4] fix(hub): limit eager scripts to existing dock entry points --- docs/content/1.guide/17.client-context.md | 6 +-- docs/content/8.references/6.hub-api.md | 4 +- .../state/client-script.integration.test.ts | 42 +++---------------- packages/hub-ui/src/client/state/context.ts | 22 ++++------ .../hub-ui/src/client/state/setup-script.ts | 21 ++++------ .../hub/src/client/__tests__/host.test.ts | 35 ---------------- packages/hub/src/client/host.ts | 27 ++++++------ packages/hub/src/types/docks.ts | 4 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 +- 9 files changed, 46 insertions(+), 117 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 7914cff6a..e8be395ae 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -67,16 +67,16 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren ## Dock client scripts -A client script is a `ClientScriptEntry`: `{ importFrom, importName?, eager? }`. `importName` defaults to `'default'` and `eager` defaults to `false`. Every dock entry can carry a page-level `clientScript`, including JSON-render entries. By default, it runs inside the host page when the dock entry is first activated, before its activation script. An `action` entry also runs its `action` on each activation, while a `custom-render` entry initializes its `renderer` after selection so it can mount into the panel. +A client script is a `ClientScriptEntry`: `{ importFrom, importName?, eager? }`. `importName` defaults to `'default'` and `eager` defaults to `false`. An `iframe` entry's optional `clientScript` runs inside the host page when the dock entry is first activated. An `action` entry runs its `action` on each activation, while a `custom-render` entry initializes its `renderer` after selection so it can mount into the panel. -Set `eager: true` on a descriptor to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands. Page setup and activation scripts initialize independently, even when they import the same export. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)). +Set `eager: true` on a descriptor to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)). The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras: - **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). - **`messages`**: an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`). -Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock, script role and import descriptor. Action clicks always execute again. +Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again. ### Shipping a client script diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index cee88dd31..0f4cd21cf 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -131,9 +131,9 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru |---|---|---| | `action` | `action` | when the dock button is activated | | `custom-render` | `renderer` | to render the entry's panel | -| Every user dock entry | `clientScript` (optional) | inside the host page on first activation, before the activation script | +| `iframe` | `clientScript` (optional) | inside the host page on first activation | -`ClientScriptEntry.eager` defaults to `false`. Set it to `true` to initialize that script after RPC trust, before dock activation. Page setup and activation scripts have separate caches; action clicks execute on every activation. +`ClientScriptEntry.eager` defaults to `false`. Set it to `true` to initialize that script after RPC trust, before dock activation. Setup is cached per RPC connection and dock; action clicks execute on every activation. ## Frame-nav messages diff --git a/packages/hub-ui/src/client/state/client-script.integration.test.ts b/packages/hub-ui/src/client/state/client-script.integration.test.ts index 48421488a..09b2725ad 100644 --- a/packages/hub-ui/src/client/state/client-script.integration.test.ts +++ b/packages/hub-ui/src/client/state/client-script.integration.test.ts @@ -1,6 +1,5 @@ import type { DevframeDockEntry } from '@devframes/hub' import type { DevframeRpcClient } from '@devframes/hub/client' -import type {} from '@devframes/json-render/hub' import type { SharedState } from 'devframe/utils/shared-state' import { DEVFRAME_EVENTS } from 'devframe/constants' import { createEventEmitter } from 'devframe/utils/events' @@ -84,7 +83,7 @@ describe('dock client scripts', () => { }) }) -it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock activation, once per RPC client', async (type) => { +it('starts an eager iframe script before dock activation, once per RPC client', async () => { expect.assertions(3) let attempts = 0 globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { @@ -93,7 +92,7 @@ it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock const { rpc, sharedStates } = createStubRpc() const context = await createDocksContext('embedded', rpc) const clientScript = { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } - const entry = { id: `background-${type}`, type, title: 'Background page script', icon: 'ph:browser', url: '/fixture', view: { stateKey: 'fixture:view' }, clientScript } satisfies DevframeDockEntry + const entry = { id: 'background-iframe', type: 'iframe', title: 'Background page script', icon: 'ph:browser', url: '/fixture', clientScript } satisfies DevframeDockEntry sharedStates.get('devframe:docks')!.push([entry]) await expect.poll(() => attempts).toBe(1) expect(context.docks.selectedId).toBeNull() @@ -180,35 +179,6 @@ it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', a expect(attempt).toHaveBeenCalledOnce() }) -it.each(['action', 'custom-render'] as const)('keeps the %s activation independent of its eager page script', async (type) => { - expect.assertions(5) - const attempt = vi.fn() - globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt - const { rpc, sharedStates } = createStubRpc() - const context = await createDocksContext('embedded', rpc) - const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } - const entry = { - id: `two-scripts-${type}`, - type, - title: 'Independent scripts', - icon: 'ph:play', - action: script, - renderer: script, - clientScript: { ...script, eager: true }, - } satisfies DevframeDockEntry - sharedStates.get('devframe:docks')!.push([entry]) - await expect.poll(() => attempt.mock.calls.length).toBe(1) - expect(context.docks.selectedId).toBeNull() - await context.docks.switchEntry(entry.id) - expect(attempt).toHaveBeenCalledTimes(2) - await context.docks.switchEntry(null) - await context.docks.switchEntry(entry.id) - expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) - sharedStates.get('devframe:docks')!.push([{ ...entry }]) - await nextTick() - expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) -}) - it('awaits an eager page setup before activation and retries it after failure', async () => { expect.assertions(5) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -225,11 +195,11 @@ it('awaits an eager page setup before activation and retries it after failure', const context = await createDocksContext('embedded', rpc) const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' } const entry = { - id: 'retry-page-before-renderer', - type: 'custom-render', + id: 'retry-page-before-activation', + type: 'iframe', title: 'Retry page', icon: 'ph:play', - renderer: script, + url: '/fixture', clientScript: { ...script, eager: true }, } satisfies DevframeDockEntry sharedStates.get('devframe:docks')!.push([entry]) @@ -239,5 +209,5 @@ it('awaits an eager page setup before activation and retries it after failure', expect(context.docks.selectedId).toBeNull() complete() await expect(activation).resolves.toBe(true) - expect(attempts).toBe(3) + expect(attempts).toBe(2) }) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index b4f317ae2..eee942772 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -17,7 +17,7 @@ import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_ST import { createClientMessagesClient } from './messages-client' import { dockCommandId } from './palette' import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup' -import { dockScript, executeSetupScript } from './setup-script' +import { clientScriptOf, executeSetupScript } from './setup-script' const docksContextByRpc = new WeakMap() export async function createDocksContext( @@ -238,16 +238,14 @@ export async function createDocksContext( } async function runPageScript(entry: DevframeDockEntry): Promise { - if (entry.type === '~builtin' || !entry.clientScript) + if (entry.type !== 'iframe' || !entry.clientScript) return - await executeSetupScript(entry, scriptContext(entry), 'clientScript') + await executeSetupScript(entry, scriptContext(entry)) } async function runActivationScript(entry: DevframeDockEntry): Promise { - if (entry.type === 'action') - await executeSetupScript(entry, scriptContext(entry), 'action') - else if (entry.type === 'custom-render') - await executeSetupScript(entry, scriptContext(entry), 'renderer') + if (entry.type === 'action' || entry.type === 'custom-render') + await executeSetupScript(entry, scriptContext(entry)) } /** Only explicitly eager descriptors run before activation, after the RPC connection is trusted. */ @@ -257,12 +255,10 @@ export async function createDocksContext( for (const entry of entries.value) { if (entry.type === '~builtin') continue - for (const role of ['clientScript', 'action', 'renderer'] as const) { - if (!dockScript(entry, role)?.eager) - continue - /** Setup reports failures and allows the next activation or publication to retry. */ - void executeSetupScript(entry, scriptContext(entry), role, true).catch(() => {}) - } + if (!clientScriptOf(entry)?.eager) + continue + /** Setup reports failures and allows the next activation or publication to retry. */ + void executeSetupScript(entry, scriptContext(entry), true).catch(() => {}) } } diff --git a/packages/hub-ui/src/client/state/setup-script.ts b/packages/hub-ui/src/client/state/setup-script.ts index ec158953f..fbecda14e 100644 --- a/packages/hub-ui/src/client/state/setup-script.ts +++ b/packages/hub-ui/src/client/state/setup-script.ts @@ -2,15 +2,13 @@ import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub' import type { DevframeRpcClient, DockClientScriptContext } from '@devframes/hub/client' import { clientScriptFailureHint, resolveClientModuleSpecifier } from '@devframes/hub/client' -export type DockScriptRole = 'clientScript' | 'action' | 'renderer' - -/** Page setup and activation scripts have independent initialization lifetimes. */ -export function dockScript(entry: DevframeDockUserEntry, role: DockScriptRole): ClientScriptEntry | undefined { - if (role === 'clientScript') +/** Resolve the existing script field for this dock kind. */ +export function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined { + if (entry.type === 'iframe') return entry.clientScript - if (role === 'action' && entry.type === 'action') + if (entry.type === 'action') return entry.action - if (role === 'renderer' && entry.type === 'custom-render') + if (entry.type === 'custom-render') return entry.renderer } @@ -50,20 +48,19 @@ async function _executeSetupScript( } const setupPromisesByRpc = new WeakMap>>() -/** Cache setup per RPC connection, dock and role; explicit action clicks always run again. */ +/** Cache setup per RPC connection and dock; explicit action clicks always run again. */ export function executeSetupScript( entry: DevframeDockUserEntry, context: DockClientScriptContext, - role: DockScriptRole, - cache = role !== 'action', + cache = entry.type !== 'action', ): Promise { - const script = dockScript(entry, role) + const script = clientScriptOf(entry) let setupPromises = setupPromisesByRpc.get(context.rpc) if (!setupPromises) { setupPromises = new Map() setupPromisesByRpc.set(context.rpc, setupPromises) } - const key = JSON.stringify([entry.id, role, script?.importFrom, script?.importName ?? 'default']) + const key = JSON.stringify([entry.id, script?.importFrom, script?.importName ?? 'default']) const existing = setupPromises.get(key) if (cache && existing) return existing diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index b376b9da1..acb9cd1ab 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -425,41 +425,6 @@ describe('createDevframeClientRuntime', () => { }) }) -it.each(['action', 'custom-render'] as const)('loads independent page and activation scripts in the headless %s runtime', async (type) => { - expect.assertions(5) - const { rpc, states } = createStubRpc() - const runtime = await createDevframeClientRuntime({ rpc }) - const attempt = vi.fn() - const fixture = globalThis as typeof globalThis & { __DF_ROLE_TEST__?: () => void } - fixture.__DF_ROLE_TEST__ = attempt - const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DF_ROLE_TEST__()' } - const entry = { - id: 'independent-scripts', - type, - title: 'Independent scripts', - icon: 'ph:play', - clientScript: { ...script, eager: true }, - action: script, - renderer: script, - } as DevframeDockEntry - try { - states.get('devframe:docks')!.push([entry]) - await expect.poll(() => attempt.mock.calls.length).toBe(1) - expect(runtime.context.docks.selectedId).toBeNull() - await runtime.context.docks.switchEntry(entry.id) - expect(attempt).toHaveBeenCalledTimes(2) - await runtime.context.docks.switchEntry(null) - await runtime.context.docks.switchEntry(entry.id) - expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) - states.get('devframe:docks')!.push([{ ...entry }]) - expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2) - } - finally { - runtime.dispose() - delete fixture.__DF_ROLE_TEST__ - } -}) - it('waits for trust for eager setup and activation for lazy setup in the headless runtime', async () => { expect.assertions(5) const { rpc, states } = createStubRpc() diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index a2895b890..4997d9624 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -424,16 +424,16 @@ export async function createDevframeClientRuntime( async function preparePageScript(entry: DevframeDockEntry): Promise { if (!rpc.isTrusted) return false - if (entry.type !== '~builtin' && entry.clientScript) - await setupClientScript(entry.id, entry.clientScript, 'clientScript') + if (entry.type === 'iframe' && entry.clientScript) + await setupClientScript(entry.id, entry.clientScript) return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry } async function runActivationScript(entry: DevframeDockEntry): Promise { if (entry.type === 'action') - await setupClientScript(entry.id, entry.action, 'action', false) + await setupClientScript(entry.id, entry.action, false) else if (entry.type === 'custom-render') - await setupClientScript(entry.id, entry.renderer, 'renderer') + await setupClientScript(entry.id, entry.renderer) } async function switchEntry(id?: string | null): Promise { @@ -446,7 +446,7 @@ export async function createDevframeClientRuntime( const entry = entryToStateMap.get(next ?? '')?.entryMeta if (entry && loadScriptsEnabled && !rpc.isTrusted) return false - if (entry?.type !== '~builtin' && entry?.clientScript && loadScriptsEnabled && !await preparePageScript(entry)) + if (entry?.type === 'iframe' && entry.clientScript && loadScriptsEnabled && !await preparePageScript(entry)) return false const previous = selectedId @@ -557,22 +557,23 @@ export async function createDevframeClientRuntime( for (const entry of currentEntries()) { if (entry.type === '~builtin') continue - startEagerScript(entry.id, entry.clientScript, 'clientScript') + if (entry.type === 'iframe') + startEagerScript(entry.id, entry.clientScript) if (entry.type === 'action') - startEagerScript(entry.id, entry.action, 'action') + startEagerScript(entry.id, entry.action) else if (entry.type === 'custom-render') - startEagerScript(entry.id, entry.renderer, 'renderer') + startEagerScript(entry.id, entry.renderer) } } - function startEagerScript(entryId: string, script: ClientScriptEntry | undefined, role: string): void { + function startEagerScript(entryId: string, script: ClientScriptEntry | undefined): void { if (script?.eager) - void setupClientScript(entryId, script, role).catch(() => {}) + void setupClientScript(entryId, script).catch(() => {}) } - /** Keep page and activation setup separate even when they import the same export. */ - function setupClientScript(entryId: string, script: ClientScriptEntry, role: string, cache = true): Promise { - const key = JSON.stringify([entryId, role, script.importFrom, script.importName ?? 'default']) + /** Share eager and activation setup; explicit action invocations bypass the cache. */ + function setupClientScript(entryId: string, script: ClientScriptEntry, cache = true): Promise { + const key = JSON.stringify([entryId, script.importFrom, script.importName ?? 'default']) const existing = loadedScripts.get(key) if (cache && existing) return existing diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index c81378320..513a28ed8 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -81,8 +81,6 @@ export type DevframeDockEntryIcon = string | { light: string, dark: string } export type DevframeDockBadgeVariant = 'default' | 'info' | 'success' | 'warning' | 'danger' export interface DevframeDockEntryBase { - /** Page script run in the host page when activated, or after trust when `eager: true`. */ - clientScript?: ClientScriptEntry id: string title: string icon: DevframeDockEntryIcon @@ -238,6 +236,8 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { * share a `frameId` may live in one group, several groups, or none. */ frameId?: string + /** Optional page script, initialized on activation or after trust when `eager: true`. */ + clientScript?: ClientScriptEntry /** * Soft-navigation target within a shared frame. Set on a **member** dock * (one of several docks sharing a {@link frameId}) to describe which internal diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 003b70bd2..c3e70df81 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -83,7 +83,6 @@ export interface DevframeDockActivation { params?: Record; } export interface DevframeDockEntryBase { - clientScript?: ClientScriptEntry; id: string; title: string; icon: DevframeDockEntryIcon; @@ -317,6 +316,7 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { openExternal?: boolean; }; frameId?: string; + clientScript?: ClientScriptEntry; navTarget?: NavTarget; subTabs?: FrameSubTabsConfig; remote?: boolean | RemoteDockOptions; From 91291de9f351ee7bac8664dfa0c46636bc81393c Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 14 Sep 2026 15:29:36 +0200 Subject: [PATCH 4/4] fix(hub): recheck trust after client script imports --- .../state/client-script.integration.test.ts | 78 ++++++++++++++++++ packages/hub-ui/src/client/state/context.ts | 2 + .../hub-ui/src/client/state/setup-script.ts | 3 + .../hub/src/client/__tests__/host.test.ts | 80 +++++++++++++++++++ packages/hub/src/client/host.ts | 7 +- 5 files changed, 168 insertions(+), 2 deletions(-) diff --git a/packages/hub-ui/src/client/state/client-script.integration.test.ts b/packages/hub-ui/src/client/state/client-script.integration.test.ts index 09b2725ad..2e6b4d608 100644 --- a/packages/hub-ui/src/client/state/client-script.integration.test.ts +++ b/packages/hub-ui/src/client/state/client-script.integration.test.ts @@ -211,3 +211,81 @@ it('awaits an eager page setup before activation and retries it after failure', await expect(activation).resolves.toBe(true) expect(attempts).toBe(2) }) + +it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => { + expect.assertions(6) + const reportError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { rpc, sharedStates: states } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const docks = context.docks + const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_UI__?: () => Promise, __DF_IMPORT_SETUP_UI__?: () => void } + let releaseImport!: () => void + const importGate = new Promise((resolve) => { + releaseImport = resolve + }) + const importing = vi.fn(() => importGate) + const setup = vi.fn() + fixture.__DF_IMPORT_GATE_UI__ = importing + fixture.__DF_IMPORT_SETUP_UI__ = setup + const entry = { + id: `revoked-import-${eager}`, + type: 'iframe', + title: 'Revoked import', + icon: 'ph:browser', + url: '/fixture', + clientScript: { + eager, + importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_UI__(); export default () => globalThis.__DF_IMPORT_SETUP_UI__(); // ${eager}`, + }, + } satisfies DevframeDockEntry + try { + states.get('devframe:docks')!.push([entry]) + const activation = docks.switchEntry(entry.id) + const rejected = expect(activation).rejects.toThrow('no longer trusted') + await expect.poll(() => importing.mock.calls.length).toBe(1) + Object.assign(rpc, { isTrusted: false }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false) + releaseImport() + await rejected + expect(setup).not.toHaveBeenCalled() + expect(docks.selectedId).toBeNull() + Object.assign(rpc, { isTrusted: true }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) + await expect(docks.switchEntry(entry.id)).resolves.toBe(true) + expect(setup).toHaveBeenCalledOnce() + } + finally { + releaseImport() + delete fixture.__DF_IMPORT_GATE_UI__ + delete fixture.__DF_IMPORT_SETUP_UI__ + reportError.mockRestore() + } +}) + +it('does not activate an iframe when trust is lost while its setup completes', async () => { + expect.assertions(2) + const { rpc, sharedStates: states } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const docks = context.docks + const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_UI__?: () => void } + fixture.__DF_SETUP_REVOKE_UI__ = () => { + Object.assign(rpc, { isTrusted: false }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false) + } + const entry = { + id: 'revoked-during-setup', + type: 'iframe', + title: 'Revoked setup', + icon: 'ph:browser', + url: '/fixture', + clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_UI__()' }, + } satisfies DevframeDockEntry + try { + states.get('devframe:docks')!.push([entry]) + await expect(docks.switchEntry(entry.id)).resolves.toBe(false) + expect(docks.selectedId).toBeNull() + } + finally { + delete fixture.__DF_SETUP_REVOKE_UI__ + } +}) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index eee942772..a166ec30c 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -309,6 +309,8 @@ export async function createDocksContext( if (!rpc.isTrusted) return false await runPageScript(entry) + if (!rpc.isTrusted) + return false initialRestorePending.value = false selectedDockId.value = entry.id diff --git a/packages/hub-ui/src/client/state/setup-script.ts b/packages/hub-ui/src/client/state/setup-script.ts index fbecda14e..680da220c 100644 --- a/packages/hub-ui/src/client/state/setup-script.ts +++ b/packages/hub-ui/src/client/state/setup-script.ts @@ -34,6 +34,9 @@ async function _executeSetupScript( const fn = mod[script.importName ?? 'default'] if (typeof fn !== 'function') throw new Error(`[@devframes/hub-ui] "${specifier}" exports no callable "${script.importName ?? 'default'}"`) + /** Trust may change while the module is loading; rejection keeps setup retryable. */ + if (!context.rpc.isTrusted) + throw new Error('[@devframes/hub-ui] RPC client is no longer trusted') await fn(context) } catch (error) { diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index acb9cd1ab..7f87b2d3c 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -454,3 +454,83 @@ it('waits for trust for eager setup and activation for lazy setup in the headles delete fixture.__DF_LAZY_TEST__ } }) + +it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => { + expect.assertions(6) + const reportError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { rpc, states } = createStubRpc() + const runtime = await createDevframeClientRuntime({ rpc }) + const docks = runtime.context.docks + const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_HEADLESS__?: () => Promise, __DF_IMPORT_SETUP_HEADLESS__?: () => void } + let releaseImport!: () => void + const importGate = new Promise((resolve) => { + releaseImport = resolve + }) + const importing = vi.fn(() => importGate) + const setup = vi.fn() + fixture.__DF_IMPORT_GATE_HEADLESS__ = importing + fixture.__DF_IMPORT_SETUP_HEADLESS__ = setup + const entry = { + id: `revoked-import-${eager}`, + type: 'iframe', + title: 'Revoked import', + icon: 'ph:browser', + url: '/fixture', + clientScript: { + eager, + importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_HEADLESS__(); export default () => globalThis.__DF_IMPORT_SETUP_HEADLESS__(); // ${eager}`, + }, + } satisfies DevframeDockEntry + try { + states.get('devframe:docks')!.push([entry]) + const activation = docks.switchEntry(entry.id) + const rejected = expect(activation).rejects.toThrow('no longer trusted') + await expect.poll(() => importing.mock.calls.length).toBe(1) + Object.assign(rpc, { isTrusted: false }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false) + releaseImport() + await rejected + expect(setup).not.toHaveBeenCalled() + expect(docks.selectedId).toBeNull() + Object.assign(rpc, { isTrusted: true }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) + await expect(docks.switchEntry(entry.id)).resolves.toBe(true) + expect(setup).toHaveBeenCalledOnce() + } + finally { + releaseImport() + delete fixture.__DF_IMPORT_GATE_HEADLESS__ + delete fixture.__DF_IMPORT_SETUP_HEADLESS__ + reportError.mockRestore() + runtime.dispose() + } +}) + +it('does not activate an iframe when trust is lost while its setup completes', async () => { + expect.assertions(2) + const { rpc, states } = createStubRpc() + const runtime = await createDevframeClientRuntime({ rpc }) + const docks = runtime.context.docks + const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_HEADLESS__?: () => void } + fixture.__DF_SETUP_REVOKE_HEADLESS__ = () => { + Object.assign(rpc, { isTrusted: false }) + rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false) + } + const entry = { + id: 'revoked-during-setup', + type: 'iframe', + title: 'Revoked setup', + icon: 'ph:browser', + url: '/fixture', + clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_HEADLESS__()' }, + } satisfies DevframeDockEntry + try { + states.get('devframe:docks')!.push([entry]) + await expect(docks.switchEntry(entry.id)).resolves.toBe(false) + expect(docks.selectedId).toBeNull() + } + finally { + delete fixture.__DF_SETUP_REVOKE_HEADLESS__ + runtime.dispose() + } +}) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 4997d9624..4c7d4c332 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -426,7 +426,7 @@ export async function createDevframeClientRuntime( return false if (entry.type === 'iframe' && entry.clientScript) await setupClientScript(entry.id, entry.clientScript) - return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry + return !disposed && rpc.isTrusted && entryToStateMap.get(entry.id)?.entryMeta === entry } async function runActivationScript(entry: DevframeDockEntry): Promise { @@ -606,8 +606,11 @@ export async function createDevframeClientRuntime( if (typeof fn !== 'function') throw new Error(`[@devframes/hub] "${specifier}" exports no callable "${script.importName ?? 'default'}"`) const current = entryToStateMap.get(entryId) - if (!current || disposed || !rpc.isTrusted) + if (!current || disposed) return + /** Reject instead of caching skipped setup so re-authentication can retry it. */ + if (!rpc.isTrusted) + throw new Error('[@devframes/hub] RPC client is no longer trusted') // 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 } })