diff --git a/packages/v1-endpoint/src/common.ts b/packages/v1-endpoint/src/common.ts index d6f1a68c..d680b685 100644 --- a/packages/v1-endpoint/src/common.ts +++ b/packages/v1-endpoint/src/common.ts @@ -1 +1,2 @@ export * from './common/targets' +export { toScopedHostApiMethod } from './common/extension' diff --git a/packages/v1-endpoint/src/common/extension.ts b/packages/v1-endpoint/src/common/extension.ts index 8345d155..f2f06f9d 100644 --- a/packages/v1-endpoint/src/common/extension.ts +++ b/packages/v1-endpoint/src/common/extension.ts @@ -15,7 +15,8 @@ import type { RunIdentity as WidgetRunIdentity } from './widgets' export interface RemoteApi { run ( channel: Channel, - config: WidgetRunConfig | PageRunIdentity + config: WidgetRunConfig | PageRunIdentity, + hostApiScope?: string, ): Promise; release (config: WidgetRunIdentity | PageRunIdentity): void; @@ -28,3 +29,6 @@ export type EndpointApi = & CustomContextAccessor & HostApi & RemoteApi + +export const toScopedHostApiMethod = (scope: string, method: string): string => + `@retailcrm/embed-ui/run/${scope}/${method}` diff --git a/packages/v1-endpoint/src/remote/endpoint.ts b/packages/v1-endpoint/src/remote/endpoint.ts index 5eba9f89..c6f8adcd 100644 --- a/packages/v1-endpoint/src/remote/endpoint.ts +++ b/packages/v1-endpoint/src/remote/endpoint.ts @@ -23,6 +23,7 @@ import { release, retain } from '@remote-ui/rpc' import { defineRunner as definePageRunner } from './pages' import { defineRunner as defineWidgetRunner } from './widgets' +import { toScopedHostApiMethod } from '../common/extension' export type Runner = { widget: WidgetRunner; @@ -145,14 +146,15 @@ export const createEndpoint = ( endpoint.expose({ async run ( channel: Channel, - config: RunConfig + config: RunConfig, + hostApiScope?: string ) { const run = createRun(channel) runs.replace(config, run) retain(channel) try { - const mounting = mountRun(run, config, runner, endpoint) + const mounting = mountRun(run, config, runner, endpoint, hostApiScope) await Promise.race([mounting, run.cancellation]) } catch (error) { @@ -179,14 +181,16 @@ const mountRun = async ( run: RemoteRun, config: RunConfig, runner: Runner, - endpoint: Endpoint + endpoint: Endpoint, + hostApiScope?: string ): Promise => { const root = await mountEndpointRoot(run.channel) as Parameters[0] const { createApp } = createRemoteRenderer(root) const pinia = createPinia() + const hostEndpoint = createHostEndpoint(endpoint, hostApiScope) - pinia.use(injectEndpoint(endpoint)) - pinia.use(injectAccessor(endpoint)) + pinia.use(injectEndpoint(hostEndpoint)) + pinia.use(injectAccessor(hostEndpoint)) run.destroy = 'id' in config ? await runner.widget.run(createApp, root, pinia, config.target) @@ -196,3 +200,23 @@ const mountRun = async ( disposeRun(run) } } + +const createHostEndpoint = ( + endpoint: Endpoint, + scope?: string +): Endpoint => { + if (!scope) { + return endpoint + } + + return { + ...endpoint, + call: new Proxy(endpoint.call, { + get (target, method, receiver) { + return typeof method === 'string' + ? Reflect.get(target, toScopedHostApiMethod(scope, method), receiver) + : Reflect.get(target, method, receiver) + }, + }), + } +} diff --git a/packages/v1-endpoint/tests/__fixtures__/worker.ts b/packages/v1-endpoint/tests/__fixtures__/worker.ts index 81a444e3..5c4aa97c 100644 --- a/packages/v1-endpoint/tests/__fixtures__/worker.ts +++ b/packages/v1-endpoint/tests/__fixtures__/worker.ts @@ -1,3 +1,4 @@ +import { defineStore } from 'pinia' import { h, onMounted, ref } from 'vue' import { defineRunner as defineRemoteRunner, runEndpoint } from '../../src/remote' @@ -9,6 +10,14 @@ const WIDGET_MOUNT_PENDING = 'test:widget-mount-pending' let delayWidgetMount = false let continueWidgetMount = () => undefined +const useHostApi = defineStore('worker-host-api', { + actions: { + async getPathname () { + return (await this.endpoint.call.getLocation()).pathname + }, + }, +}) + self.addEventListener('message', event => { if (event.data?.type === DELAY_WIDGET_MOUNT) { delayWidgetMount = true @@ -73,6 +82,8 @@ runEndpoint(defineRemoteRunner({ setup (props: { target: string }) { const count = ref(0) + const pathname = ref('unread') + const hostApi = useHostApi() onMounted(() => { setTimeout(() => { @@ -91,6 +102,17 @@ runEndpoint(defineRemoteRunner({ count.value += 1 }, }, 'inc widget'), + + h('button', { + 'data-qa': 'worker-widget:read-location', + onClick: async () => { + pathname.value = await hostApi.getPathname() + }, + }, 'read location'), + + h('div', { + 'data-qa': 'worker-widget:location', + }, pathname.value), ]) }, }, waitBeforeWidgetMount], diff --git a/packages/v1-endpoint/tests/endpoint.test.ts b/packages/v1-endpoint/tests/endpoint.test.ts index 35d944a1..f5a6aab8 100644 --- a/packages/v1-endpoint/tests/endpoint.test.ts +++ b/packages/v1-endpoint/tests/endpoint.test.ts @@ -15,6 +15,7 @@ import { import { createApp } from 'vue' import { createEndpoint as createRpcEndpoint } from '@remote-ui/rpc' +import { defineStore } from 'pinia' import { flushPromises } from '@vue/test-utils' import { fromMessagePort } from '@remote-ui/rpc' import { h, ref } from 'vue' @@ -23,6 +24,7 @@ import { createEndpoint as createRemoteEndpoint, defineRunner as defineRemoteRunner, } from '../src/remote' +import { toScopedHostApiMethod } from '../src/common' const { exposeEndpointApi, @@ -180,6 +182,56 @@ test('runs widget and page simultaneously and releases independently via defineR expect(pageMount.querySelector('[data-qa="page:orders"]')).toBeNull() }) +test('routes host API calls to the scope of each simultaneous run', async () => { + const { port1, port2 } = new MessageChannel() + const host = createRpcEndpoint(fromMessagePort(port1)) + const channel = fromMessagePort(port2) + const firstReceiver = createReceiver() + const secondReceiver = createReceiver() + const locations: string[] = [] + const useHostApi = defineStore('scoped-host-api', { + actions: { + async readLocation () { + locations.push((await this.endpoint.call.getLocation()).pathname) + }, + }, + }) + + port1.start() + port2.start() + + host.expose({ + [toScopedHostApiMethod('first', 'getLocation')]: () => ({ + pathname: '/first', + search: '', + hash: '', + query: {}, + }), + [toScopedHostApiMethod('second', 'getLocation')]: () => ({ + pathname: '/second', + search: '', + hash: '', + query: {}, + }), + }) + + createRemoteEndpoint(defineRemoteRunner({ + pages: [{ render: () => null }], + widgets: [{ render: () => null }, (_app, pinia) => useHostApi(pinia).readLocation()], + }), channel) + + await host.call.run(firstReceiver.receive, { + id: 'first-widget', + target: 'order/card:common.before', + }, 'first') + await host.call.run(secondReceiver.receive, { + id: 'second-widget', + target: 'order/card:common.after', + }, 'second') + + expect(locations).toEqual(['/first', '/second']) +}) + test('replaces run with same widget id and supports reset via defineRemoteRunner', async () => { const { port1, port2 } = new MessageChannel() const host = createRpcEndpoint(fromMessagePort(port1)) diff --git a/packages/v1-endpoint/tests/worker.test.browser.ts b/packages/v1-endpoint/tests/worker.test.browser.ts index b00cf9af..bb0f0bdd 100644 --- a/packages/v1-endpoint/tests/worker.test.browser.ts +++ b/packages/v1-endpoint/tests/worker.test.browser.ts @@ -16,6 +16,8 @@ import { test, } from 'vitest' +import { toScopedHostApiMethod } from '../src/common' + function createHostApp (receiver: Receiver) { const provider = createProvider() @@ -163,3 +165,54 @@ test('release cancels a widget while its worker runner is mounting', async () => worker.terminate() } }) + +test('routes simultaneous worker host API calls through their run scopes', async () => { + const worker = new Worker(new URL('./__fixtures__/worker.ts', import.meta.url), { type: 'module' }) + const host = createRpcEndpoint(worker) + const firstReceiver = createReceiver() + const secondReceiver = createReceiver() + const firstMount = document.createElement('div') + const secondMount = document.createElement('div') + + mountRoot?.appendChild(firstMount) + mountRoot?.appendChild(secondMount) + + createHostApp(firstReceiver).mount(firstMount) + createHostApp(secondReceiver).mount(secondMount) + + host.expose({ + [toScopedHostApiMethod('first', 'getLocation')]: () => ({ + pathname: '/first', + search: '', + hash: '', + query: {}, + }), + [toScopedHostApiMethod('second', 'getLocation')]: () => ({ + pathname: '/second', + search: '', + hash: '', + query: {}, + }), + }) + + try { + await host.call.run(firstReceiver.receive, { + id: 'first-widget', + target: 'order/card:common.before', + }, 'first') + await host.call.run(secondReceiver.receive, { + id: 'second-widget', + target: 'order/card:common.after', + }, 'second') + await firstReceiver.flush() + await secondReceiver.flush() + + firstMount.querySelector('[data-qa="worker-widget:read-location"]')?.click() + secondMount.querySelector('[data-qa="worker-widget:read-location"]')?.click() + + await waitForText(firstReceiver, firstMount, '[data-qa="worker-widget:location"]', '/first') + await waitForText(secondReceiver, secondMount, '[data-qa="worker-widget:location"]', '/second') + } finally { + worker.terminate() + } +})