From bc98b1e8495542686c5edc1305489e48c5bb2d8d Mon Sep 17 00:00:00 2001 From: arlo Date: Wed, 7 Oct 2026 14:03:28 +0800 Subject: [PATCH] fix: restore embedded dock and rpc auth in web container --- .../src/node/__tests__/rpc-core.test.ts | 100 ++++++++++++++++++ packages/devframe/src/node/rpc-core.ts | 26 +++-- packages/hub-ui/src/client/embedded/index.ts | 3 +- .../src/client/embedded/is-inside-hub.test.ts | 27 +++++ .../src/client/embedded/is-inside-hub.ts | 14 +++ 5 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 packages/devframe/src/node/__tests__/rpc-core.test.ts create mode 100644 packages/hub-ui/src/client/embedded/is-inside-hub.test.ts create mode 100644 packages/hub-ui/src/client/embedded/is-inside-hub.ts diff --git a/packages/devframe/src/node/__tests__/rpc-core.test.ts b/packages/devframe/src/node/__tests__/rpc-core.test.ts new file mode 100644 index 000000000..7242dc70b --- /dev/null +++ b/packages/devframe/src/node/__tests__/rpc-core.test.ts @@ -0,0 +1,100 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { CreateContextRpcServerOptions } from '../rpc-core' +import { createRpcClient } from 'devframe/rpc/client' +import { expect, it, vi } from 'vitest' +import { RpcFunctionsHostImpl } from '../host-functions' +import { createContextRpcServer } from '../rpc-core' + +// Simulate WebContainer losing AsyncLocalStorage context across awaits. +vi.mock('node:async_hooks', () => ({ + AsyncLocalStorage: class { + store: unknown + run(store: unknown, callback: () => unknown) { + const previous = this.store + this.store = store + try { + return callback() + } + finally { + this.store = previous + } + } + + getStore() { return this.store } + }, +})) + +function createServer(authorize?: CreateContextRpcServerOptions['authorize']) { + const context = {} as DevframeNodeContext + const rpc = new RpcFunctionsHostImpl(context) + Object.assign(context, { rpc }) + const { rpcGroup } = createContextRpcServer({ context, authorize }) + + // Connect real birpc peers through an in-memory channel. + function connect(id: string) { + let receiveServer: (data: unknown) => void + let receiveClient: (data: unknown) => void + rpcGroup.updateChannels((channels) => { + channels.push({ + meta: { id }, + post: data => queueMicrotask(() => receiveClient(data)), + on: fn => receiveServer = fn, + }) + }) + return createRpcClient any>>({}, { + channel: { + post: data => queueMicrotask(() => receiveServer(data)), + on: fn => receiveClient = fn, + }, + rpcOptions: { timeout: 1000 }, + }) + } + + return { rpc, connect } +} + +it.each([false, true])('keeps concurrent sessions isolated with schema validation: %s', async (withSchema) => { + const { rpc, connect } = createServer() + const handler = () => rpc.getCurrentRpcSession()?.meta.id + const setup = vi.fn(async () => ({ handler })) + rpc.register({ + name: 'test:session', + type: 'query', + args: withSchema ? [{ '~standard': { version: 1, vendor: 'test', validate: async (value: unknown) => ({ value }) } }] : undefined, + setup, + }) + const first = connect('first') + const second = connect('second') + const results = await Promise.all([ + first.$call('test:session'), + second.$call('test:session'), + ]) + expect(results).toEqual(['first', 'second']) + expect(rpc.getCurrentRpcSession()).toBeUndefined() + expect(setup).toHaveBeenCalledTimes(1) +}) + +it('rejects unknown methods', async () => { + const { connect } = createServer() + await expect(connect('first').$call('test:missing')).rejects.toThrow('not found') +}) + +it('rejects unauthorized calls before running setup', async () => { + const { rpc, connect } = createServer(() => false) + const setup = vi.fn(async () => ({ handler: () => 'value' })) + rpc.register({ name: 'test:private', type: 'query', setup }) + await expect(connect('first').$call('test:private')).rejects.toThrow('not authorized') + expect(setup).not.toHaveBeenCalled() +}) + +it('returns setup errors to the caller and allows a retry', async () => { + const { rpc, connect } = createServer() + const setup = vi.fn() + .mockRejectedValueOnce(new Error('setup failed')) + .mockResolvedValue({ handler: () => 'ready' }) + rpc.register({ name: 'test:setup', type: 'query', setup }) + const client = connect('first') + await expect(client.$call('test:setup')).rejects.toThrow('setup failed') + await expect(client.$call('test:setup')).resolves.toBe('ready') + expect(setup).toHaveBeenCalledTimes(2) +}) diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts index 2d643576a..2a3708f98 100644 --- a/packages/devframe/src/node/rpc-core.ts +++ b/packages/devframe/src/node/rpc-core.ts @@ -5,6 +5,7 @@ import type { DevframeAuthHandler } from './auth' import type { RpcFunctionsHostImpl } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' import { createRpcServer } from 'devframe/rpc/server' +import { getRpcHandler, getRpcResolvedSetupResult } from '../rpc/handler' import { diagnostics } from './diagnostics' export interface CreateContextRpcServerOptions { @@ -73,7 +74,8 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions): } const rpcGroup = createRpcServer( - rpcHost.functions, + // The resolver below loads handlers from their definitions. + {} as DevframeRpcServerFunctions, { rpcOptions: { /** @@ -90,21 +92,27 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions): * the call before it ever reaches the handler. Mirrors * `packages/core/src/node/ws.ts`'s resolver. */ - resolver(name, fn) { + resolver(name) { // eslint-disable-next-line ts/no-this-alias const rpc = this - if (!fn) + const definition = rpcHost.definitions.get(name) + if (!definition) return undefined return async function (this: any, ...args) { const meta = rpc.$meta as DevframeNodeRpcSessionMeta if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any })) throw diagnostics.DF0036({ name }) - return await asyncStorage.run({ - rpc, - meta, - }, async () => { - return (await fn).apply(this, args) - }) + const inner = definition.handler + ?? (await getRpcResolvedSetupResult(definition, context)).handler + const handler = await getRpcHandler(inner + ? { + ...definition, + // Enter the session scope after argument validation, since + // WebContainer does not preserve it across awaits. + handler: (...handlerArgs) => asyncStorage.run({ rpc, meta }, () => inner.apply(this, handlerArgs)), + } + : definition, context) + return handler(...args) } }, }, diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index 8510fbb20..febbeddab 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -5,6 +5,7 @@ import { ref } from 'vue' import { applyPrimaryColor, setBranding } from '../state/branding' import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks' import { setupLocale } from '../state/locale' +import { isInsideHub } from './is-inside-hub' import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility' /** @@ -20,7 +21,7 @@ let dockEl: HTMLElement | undefined async function mountDock(): Promise { // A mounted frame's SPA runs inside the hub UI provider's iframes on the same // origin, so never stack a second dock inside them. - if (window.parent !== window) + if (isInsideHub(window)) return if (dockEl) return diff --git a/packages/hub-ui/src/client/embedded/is-inside-hub.test.ts b/packages/hub-ui/src/client/embedded/is-inside-hub.test.ts new file mode 100644 index 000000000..f5776348c --- /dev/null +++ b/packages/hub-ui/src/client/embedded/is-inside-hub.test.ts @@ -0,0 +1,27 @@ +import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client' +import { describe, expect, it } from 'vitest' +import { isInsideHub } from './is-inside-hub' + +describe('embedded dock parent', () => { + it('mounts in top-level applications', () => { + const win = { parent: {} } + Object.assign(win, { parent: win }) + expect(isInsideHub(win)).toBe(false) + }) + + it('mounts in ordinary same-origin application previews', () => { + expect(isInsideHub({ parent: {} })).toBe(false) + }) + + it('mounts in cross-origin application previews', () => { + const parent = Object.defineProperty({}, CLIENT_CONTEXT_KEY, { + get() { throw new DOMException('Cross-origin access', 'SecurityError') }, + }) + expect(isInsideHub({ parent })).toBe(false) + }) + + it('suppresses duplicate docks inside Hub panels', () => { + const parent = { [CLIENT_CONTEXT_KEY]: {} } + expect(isInsideHub({ parent })).toBe(true) + }) +}) diff --git a/packages/hub-ui/src/client/embedded/is-inside-hub.ts b/packages/hub-ui/src/client/embedded/is-inside-hub.ts new file mode 100644 index 000000000..cee4a76c7 --- /dev/null +++ b/packages/hub-ui/src/client/embedded/is-inside-hub.ts @@ -0,0 +1,14 @@ +import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client' + +/** Checks whether this window is inside a Hub panel. */ +export function isInsideHub(win: { readonly parent: object }): boolean { + if (win.parent === win) + return false + try { + return !!(win.parent as Window & { [CLIENT_CONTEXT_KEY]?: unknown })[CLIENT_CONTEXT_KEY] + } + catch { + // Cross-origin parents (e.g. StackBlitz) cannot be inspected. + return false + } +}