diff --git a/superset-embedded-sdk/package.json b/superset-embedded-sdk/package.json index 9176b5ac7b74f..a41c91ad344ea 100644 --- a/superset-embedded-sdk/package.json +++ b/superset-embedded-sdk/package.json @@ -22,7 +22,7 @@ "module": "lib/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc & babel src --out-dir lib --extensions '.ts,.tsx' & webpack --mode production", + "build": "tsc ; babel src --out-dir lib --extensions '.ts,.tsx' ; webpack --mode production", "ci:release": "node ./release-if-necessary.js", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/superset-embedded-sdk/release-if-necessary.js b/superset-embedded-sdk/release-if-necessary.js index 35dff1ce89a3d..632f8cd4b873c 100644 --- a/superset-embedded-sdk/release-if-necessary.js +++ b/superset-embedded-sdk/release-if-necessary.js @@ -21,8 +21,16 @@ const { execSync } = require('child_process'); const axios = require('axios'); const { name, version } = require('./package.json'); +function log(...args) { + console.log('[embedded-sdk-release]', ...args); +} + +function logError(...args) { + console.error('[embedded-sdk-release]', ...args); +} + (async () => { - console.log(`checking if ${name}@${version} needs releasing`); + log(`checking if ${name}@${version} needs releasing`); const packageUrl = `https://registry.npmjs.org/${name}/${version}`; // npm commands output a bunch of garbage in the edge cases, @@ -33,14 +41,21 @@ const { name, version } = require('./package.json'); }); if (status === 200) { - console.log('version already exists on npm, exiting'); + log('version already exists on npm, exiting'); } else if (status === 404) { - console.log('release required, building'); - execSync('npm run build'); - execSync('npm publish --access public'); - console.log(`published ${version} to npm`); + log('release required, building'); + try { + execSync('npm run build', { stdio: 'pipe' }); + log('build successful, publishing') + execSync('npm publish --access public', { stdio: 'pipe' }); + log(`published ${version} to npm`); + } catch (err) { + console.error(String(err.stdout)); + logError('Encountered an error, details should be above'); + process.exitCode = 1; + } } else { - console.error(`ERROR: Received unexpected http status code ${status} from GET ${packageUrl} + logError(`ERROR: Received unexpected http status code ${status} from GET ${packageUrl} The embedded sdk release script might need to be fixed, or maybe you just need to try again later.`); process.exitCode = 1; } diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index 3bcebd5a1e534..d29d701d4155a 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -19,6 +19,9 @@ import { IFRAME_COMMS_MESSAGE_TYPE } from './const'; +// We can swap this out for the actual switchboard package once it gets published +import { Switchboard } from '../../superset-frontend/packages/superset-ui-switchboard/src/switchboard'; + /** * The function to fetch a guest token from your Host App's backend server. * The Host App backend must supply an API endpoint @@ -35,6 +38,17 @@ export type EmbedDashboardParams = { mountPoint: HTMLElement /** A function to fetch a guest token from the Host App's backend server */ fetchGuestToken: GuestTokenFetchFn + /** Are we in debug mode? */ + debug?: boolean +} + +export type Size = { + width: number, height: number +} + +export type EmbeddedDashboard = { + getScrollSize: () => Promise + unmount: () => void } /** @@ -44,15 +58,18 @@ export async function embedDashboard({ id, supersetDomain, mountPoint, - fetchGuestToken -}: EmbedDashboardParams) { + fetchGuestToken, + debug = false +}: EmbedDashboardParams): Promise { function log(...info: unknown[]) { - console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info); + if (debug) { + console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info); + } } log('embedding'); - async function mountIframe(): Promise { + async function mountIframe(): Promise { return new Promise(resolve => { const iframe = document.createElement('iframe'); @@ -83,7 +100,7 @@ export async function embedDashboard({ log('sent message channel to the iframe'); // return our port from the promise - resolve(ourPort); + resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug })); }); iframe.src = `${supersetDomain}/dashboard/${id}/embedded`; @@ -94,10 +111,10 @@ export async function embedDashboard({ const [guestToken, ourPort] = await Promise.all([ fetchGuestToken(), - mountIframe() + mountIframe(), ]); - ourPort.postMessage({ guestToken }); + ourPort.emit('guestToken', { guestToken }); log('sent guest token'); function unmount() { @@ -105,7 +122,10 @@ export async function embedDashboard({ mountPoint.replaceChildren(); } + const getScrollSize = () => ourPort.get('getScrollSize'); + return { - unmount + getScrollSize, + unmount, }; } diff --git a/superset-embedded-sdk/webpack.config.js b/superset-embedded-sdk/webpack.config.js index 84f33bdbee4d1..f0bc69038da9e 100644 --- a/superset-embedded-sdk/webpack.config.js +++ b/superset-embedded-sdk/webpack.config.js @@ -35,7 +35,7 @@ module.exports = { module: { rules: [ { - test: /\.ts$/, + test: /\.[tj]s$/, // babel-loader is faster than ts-loader because it ignores types. // We do type checking in a separate process, so that's fine. use: 'babel-loader', @@ -44,6 +44,6 @@ module.exports = { ], }, resolve: { - extensions: ['.ts'], + extensions: ['.ts', '.js'], }, }; diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 45492060603b1..a3dc019f6acff 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -48,6 +48,7 @@ "@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table", "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/preset-chart-xy": "file:./plugins/preset-chart-xy", + "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", "antd": "^4.9.4", @@ -21330,6 +21331,10 @@ "resolved": "plugins/preset-chart-xy", "link": true }, + "node_modules/@superset-ui/switchboard": { + "resolved": "packages/superset-ui-switchboard", + "link": true + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", @@ -59180,6 +59185,11 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "packages/superset-ui-switchboard": { + "name": "@superset-ui/switchboard", + "version": "0.18.25", + "license": "Apache-2.0" + }, "plugins/legacy-plugin-chart-calendar": { "name": "@superset-ui/legacy-plugin-chart-calendar", "version": "0.18.25", @@ -76685,6 +76695,9 @@ } } }, + "@superset-ui/switchboard": { + "version": "file:packages/superset-ui-switchboard" + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 439322dce8677..90e2122b00665 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -108,6 +108,7 @@ "@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table", "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/preset-chart-xy": "file:./plugins/preset-chart-xy", + "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", "antd": "^4.9.4", diff --git a/superset-frontend/packages/superset-ui-switchboard/package-lock.json b/superset-frontend/packages/superset-ui-switchboard/package-lock.json new file mode 100644 index 0000000000000..5c3d95de78fbd --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "@superset-ui/switchboard", + "version": "0.18.25", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@superset-ui/switchboard", + "version": "0.18.25", + "license": "Apache-2.0" + } + } +} diff --git a/superset-frontend/packages/superset-ui-switchboard/package.json b/superset-frontend/packages/superset-ui-switchboard/package.json new file mode 100644 index 0000000000000..e224640a55cde --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/package.json @@ -0,0 +1,33 @@ +{ + "name": "@superset-ui/switchboard", + "version": "0.18.25", + "description": "Switchboard is a library to make it easier to communicate across browser windows using the MessageChannel API", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache/superset.git" + }, + "keywords": [ + "switchboard", + "iframe", + "communication", + "messagechannel", + "messageport", + "postmessage" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "homepage": "https://github.com/apache/superset#readme", + "publishConfig": { + "access": "public" + } +} diff --git a/superset-frontend/packages/superset-ui-switchboard/src/index.ts b/superset-frontend/packages/superset-ui-switchboard/src/index.ts new file mode 100644 index 0000000000000..adbd7450fc035 --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/src/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './switchboard'; diff --git a/superset-frontend/packages/superset-ui-switchboard/src/switchboard.test.ts b/superset-frontend/packages/superset-ui-switchboard/src/switchboard.test.ts new file mode 100644 index 0000000000000..fb77ab90f8a42 --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/src/switchboard.test.ts @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Switchboard } from './switchboard'; + +type EventHandler = (event: MessageEvent) => void; + +// A note on these fakes: +// +// jsdom doesn't supply a MessageChannel or a MessagePort, +// so we have to build our own unless we want to unit test in-browser. +// Might want to open a PR in jsdom: https://github.com/jsdom/jsdom/issues/2448 + +/** Matches the MessagePort api as closely as necessary (it's a small api) */ +class FakeMessagePort { + otherPort?: FakeMessagePort; + + isStarted = false; + + queue: MessageEvent[] = []; + + listeners: Set = new Set(); + + dispatchEvent(event: MessageEvent) { + if (this.isStarted) { + this.listeners.forEach(listener => { + try { + listener(event); + } catch (err) { + if (typeof this.onmessageerror === 'function') { + this.onmessageerror(err); + } + } + }); + } else { + this.queue.push(event); + } + return true; + } + + addEventListener(eventType: 'message', handler: EventHandler) { + this.listeners.add(handler); + } + + removeEventListener(eventType: 'message', handler: EventHandler) { + this.listeners.delete(handler); + } + + postMessage(data: any) { + this.otherPort!.dispatchEvent({ data } as MessageEvent); + } + + start() { + if (this.isStarted) return; + this.isStarted = true; + this.queue.forEach(event => { + this.dispatchEvent(event); + }); + this.queue = []; + } + + close() { + this.isStarted = false; + } + + onmessage: EventHandler | null = null; // not implemented, requires some kinda proxy thingy to mock correctly + + onmessageerror: ((err: any) => void) | null = null; +} + +/** Matches the MessageChannel api as closely as necessary (an even smaller api than MessagePort) */ +class FakeMessageChannel { + port1: MessagePort; + + port2: MessagePort; + + constructor() { + const port1 = new FakeMessagePort(); + const port2 = new FakeMessagePort(); + port1.otherPort = port2; + port2.otherPort = port1; + this.port1 = port1; + this.port2 = port2; + } +} + +describe('comms', () => { + let originalConsoleDebug: any = null; + let originalConsoleError: any = null; + + beforeAll(() => { + global.MessageChannel = FakeMessageChannel; // yolo + originalConsoleDebug = console.debug; + originalConsoleError = console.error; + }); + + beforeEach(() => { + console.debug = jest.fn(); // silencio bruno + }); + + afterEach(() => { + console.debug = originalConsoleDebug; + console.error = originalConsoleError; + }); + + it('constructs with defaults', () => { + const sb = new Switchboard({ port: new MessageChannel().port1 }); + expect(sb).not.toBeNull(); + expect(sb).toHaveProperty('name'); + expect(sb).toHaveProperty('debugMode'); + }); + + describe('emit', () => { + it('triggers the method', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + const handler = jest.fn(); + + theirs.defineMethod('someEvent', handler); + theirs.start(); + + ours.emit('someEvent', 42); + + expect(handler).toHaveBeenCalledWith(42); + }); + + it('handles a missing method', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.start(); + channel.port2.onmessageerror = jest.fn(); + ours.emit('fakemethod'); + await new Promise(setImmediate); + expect(channel.port2.onmessageerror).not.toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('returns the value', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.defineMethod('theirMethod', ({ x }: { x: number }) => + Promise.resolve(x + 42), + ); + theirs.start(); + + const value = await ours.get('theirMethod', { x: 1 }); + + expect(value).toEqual(43); + }); + + it('removes the listener after', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.defineMethod('theirMethod', () => Promise.resolve(420)); + theirs.start(); + + expect((channel.port1 as FakeMessagePort).listeners).toHaveProperty( + 'size', + 1, + ); + const promise = ours.get('theirMethod'); + expect((channel.port1 as FakeMessagePort).listeners).toHaveProperty( + 'size', + 2, + ); + await promise; + expect((channel.port1 as FakeMessagePort).listeners).toHaveProperty( + 'size', + 1, + ); + }); + + it('can handle one way concurrency', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.defineMethod('theirMethod', () => Promise.resolve(42)); + theirs.defineMethod( + 'theirMethod2', + () => new Promise(resolve => setImmediate(() => resolve(420))), + ); + theirs.start(); + + const [value1, value2] = await Promise.all([ + ours.get('theirMethod'), + ours.get('theirMethod2'), + ]); + + expect(value1).toEqual(42); + expect(value2).toEqual(420); + }); + + it('can handle two way concurrency', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.defineMethod('theirMethod', () => Promise.resolve(42)); + ours.defineMethod( + 'ourMethod', + () => new Promise(resolve => setImmediate(() => resolve(420))), + ); + theirs.start(); + + const [value1, value2] = await Promise.all([ + ours.get('theirMethod'), + theirs.get('ourMethod'), + ]); + + expect(value1).toEqual(42); + expect(value2).toEqual(420); + }); + + it('handles when the method is not defined', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.start(); + await expect(ours.get('fakemethod')).rejects.toThrow( + '[theirs] Method "fakemethod" is not defined', + ); + }); + + it('handles when the method throws', async () => { + const channel = new MessageChannel(); + const ours = new Switchboard({ port: channel.port1, name: 'ours' }); + const theirs = new Switchboard({ port: channel.port2, name: 'theirs' }); + theirs.defineMethod('failing', () => { + throw new Error('i dont feel like writing a clever message here'); + }); + theirs.start(); + + console.error = jest.fn(); // will be restored by the afterEach + await expect(ours.get('failing')).rejects.toThrow( + '[theirs] Method "failing" threw an error', + ); + }); + + it('handles receiving an unexpected non-reply, non-error response', async () => { + const { port1, port2 } = new MessageChannel(); + const ours = new Switchboard({ port: port1, name: 'ours' }); + // This test is required for 100% coverage. But there's no way to set up these conditions + // within the switchboard interface, so we gotta hack together the ports directly. + port2.addEventListener('message', event => { + const { messageId } = event.data; + port1.dispatchEvent({ data: { messageId } } as MessageEvent); + }); + port2.start(); + + await expect(ours.get('someMethod')).rejects.toThrowError( + 'Unexpected response message', + ); + }); + }); + + it('logs in debug mode', async () => { + const { port1, port2 } = new MessageChannel(); + const ours = new Switchboard({ + port: port1, + name: 'ours', + debug: true, + }); + const theirs = new Switchboard({ + port: port2, + name: 'theirs', + debug: true, + }); + theirs.defineMethod('blah', () => {}); + theirs.start(); + await ours.emit('blah'); + expect(console.debug).toHaveBeenCalledTimes(1); + expect((console.debug as any).mock.calls[0][0]).toBe('[theirs]'); + }); + + it('does not log outside debug mode', async () => { + const { port1, port2 } = new MessageChannel(); + const ours = new Switchboard({ + port: port1, + name: 'ours', + }); + const theirs = new Switchboard({ + port: port2, + name: 'theirs', + }); + theirs.defineMethod('blah', () => {}); + theirs.start(); + await ours.emit('blah'); + expect(console.debug).toHaveBeenCalledTimes(0); + }); +}); diff --git a/superset-frontend/packages/superset-ui-switchboard/src/switchboard.ts b/superset-frontend/packages/superset-ui-switchboard/src/switchboard.ts new file mode 100644 index 0000000000000..b65ca13586377 --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/src/switchboard.ts @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type Params = { + port: MessagePort; + name?: string; + debug?: boolean; +}; + +/** + * A utility for communications between an iframe and its parent, used by the Superset embedded SDK. + * This builds useful patterns on top of the basic functionality offered by MessageChannel. + * + * Both windows instantiate a Switchboard, passing in their MessagePorts. + * Calling methods on the switchboard causes messages to be sent through the channel. + */ +export class Switchboard { + port: MessagePort; + + name: string; + + methods: Record> = {}; + + // used to make unique ids + incrementor = 1; + + debugMode: boolean; + + constructor({ port, name = 'switchboard', debug = false }: Params) { + this.port = port; + this.name = name; + this.debugMode = debug; + + port.addEventListener('message', async event => { + this.log('message received', event); + const message = event.data; + if (isGet(message)) { + // find the method, call it, and reply with the result + this.port.postMessage(await this.getMethodResult(message)); + } else if (isEmit(message)) { + const { method, args } = message; + // Find the method and call it, but no result necessary. + // Should this multicast to a set of listeners? + // Maybe, but that requires writing a bunch more code + // and I haven't found a need for it yet. + const executor = this.methods[method]; + if (executor) { + executor(args); + } + } + }); + } + + private async getMethodResult({ + messageId, + method, + args, + }: GetMessage): Promise { + const executor = this.methods[method]; + if (executor == null) { + return { + switchboardAction: Actions.ERROR, + messageId, + error: `[${this.name}] Method "${method}" is not defined`, + }; + } + try { + const result = await executor(args); + return { + switchboardAction: Actions.REPLY, + messageId, + result, + }; + } catch (err) { + this.logError(err); + return { + switchboardAction: Actions.ERROR, + messageId, + error: `[${this.name}] Method "${method}" threw an error`, + }; + } + } + + /** + * Defines a method that can be "called" from the other side by sending an event. + */ + defineMethod(methodName: string, executor: Method) { + this.methods[methodName] = executor; + } + + /** + * Calls a method registered on the other side, and returns the result. + * + * How this is accomplished: + * This switchboard sends a "get" message over the channel describing which method to call with which arguments. + * The other side's switchboard finds a method with that name, and calls it with the arguments. + * It then packages up the returned value into a "reply" message, sending it back to us across the channel. + * This switchboard has attached a listener on the channel, which will resolve with the result when a reply is detected. + * + * Instead of an arguments list, arguments are supplied as a map. + * + * @param method the name of the method to call + * @param args arguments that will be supplied. Must be serializable, no functions or other nonense. + * @returns whatever is returned from the method + */ + get(method: string, args: unknown = undefined): Promise { + return new Promise((resolve, reject) => { + // In order to "call a method" on the other side of the port, + // we will send a message with a unique id + const messageId = this.getNewMessageId(); + // attach a new listener to our port, and remove it when we get a response + const listener = (event: MessageEvent) => { + const message = event.data; + if (message.messageId !== messageId) return; + this.port.removeEventListener('message', listener); + if (isReply(message)) { + resolve(message.result); + } else { + const errStr = isError(message) + ? message.error + : 'Unexpected response message'; + reject(new Error(errStr)); + } + }; + this.port.addEventListener('message', listener); + this.port.start(); + const message: GetMessage = { + switchboardAction: Actions.GET, + method, + messageId, + args, + }; + this.port.postMessage(message); + }); + } + + /** + * Emit calls a method on the other side just like get does. + * But emit doesn't wait for a response, it just sends and forgets. + * + * @param method + * @param args + */ + emit(method: string, args: unknown = undefined) { + const message: EmitMessage = { + switchboardAction: Actions.EMIT, + method, + args, + }; + this.port.postMessage(message); + } + + start() { + this.port.start(); + } + + private log(...args: unknown[]) { + if (this.debugMode) { + console.debug(`[${this.name}]`, ...args); + } + } + + private logError(...args: unknown[]) { + console.error(`[${this.name}]`, ...args); + } + + private getNewMessageId() { + // eslint-disable-next-line no-plusplus + return `m_${this.name}_${this.incrementor++}`; + } +} + +type Method = (args: A) => R | Promise; + +// Each message we send on the channel specifies an action we want the other side to cooperate with. +enum Actions { + GET = 'get', + REPLY = 'reply', + EMIT = 'emit', + ERROR = 'error', +} + +// helper types/functions for making sure wires don't get crossed + +interface Message { + switchboardAction: Actions; +} + +interface GetMessage extends Message { + switchboardAction: Actions.GET; + method: string; + messageId: string; + args: T; +} + +function isGet(message: Message): message is GetMessage { + return message.switchboardAction === Actions.GET; +} + +interface ReplyMessage extends Message { + switchboardAction: Actions.REPLY; + messageId: string; + result: T; +} + +function isReply(message: Message): message is ReplyMessage { + return message.switchboardAction === Actions.REPLY; +} + +interface EmitMessage extends Message { + switchboardAction: Actions.EMIT; + method: string; + args: T; +} + +function isEmit(message: Message): message is EmitMessage { + return message.switchboardAction === Actions.EMIT; +} + +interface ErrorMessage extends Message { + switchboardAction: Actions.ERROR; + messageId: string; + error: string; +} + +function isError(message: Message): message is ErrorMessage { + return message.switchboardAction === Actions.ERROR; +} diff --git a/superset-frontend/packages/superset-ui-switchboard/tsconfig.json b/superset-frontend/packages/superset-ui-switchboard/tsconfig.json new file mode 100644 index 0000000000000..d26e1abe07ffb --- /dev/null +++ b/superset-frontend/packages/superset-ui-switchboard/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src" + }, + "exclude": [ + "lib", + "src/**/*.test.ts" + ], + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + "types/**/*", + "../../types/**/*" + ], + "references": [] +} diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 11a686ff2584b..977bcb7d93759 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -19,12 +19,21 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { Switchboard } from '@superset-ui/switchboard'; import { bootstrapData } from 'src/preamble'; import setupClient from 'src/setup/setupClient'; import { RootContextProviders } from 'src/views/RootContextProviders'; import ErrorBoundary from 'src/components/ErrorBoundary'; import Loading from 'src/components/Loading'; +const debugMode = process.env.WEBPACK_MODE === 'development'; + +function log(...info: unknown[]) { + if (debugMode) { + console.debug(`[superset]`, ...info); + } +} + const LazyDashboardPage = lazy( () => import( @@ -98,20 +107,31 @@ window.addEventListener('message', function (event) { try { validateMessageEvent(event); } catch (err) { - console.info('[superset] ignoring message', err, event); + log('ignoring message', err, event); return; } - console.info('[superset] received message', event); - const hostAppPort = event.ports?.[0]; - if (hostAppPort) { - hostAppPort.onmessage = function receiveMessage(event) { - console.info('[superset] received message event', event.data); - if (event.data.guestToken) { - start(event.data.guestToken); - } - }; + const port = event.ports?.[0]; + if (event.data.handshake === 'port transfer' && port) { + log('message port received', event); + + const switchboard = new Switchboard({ + port, + name: 'superset', + debug: debugMode, + }); + + switchboard.defineMethod('guestToken', ({ guestToken }) => { + start(guestToken); + }); + + switchboard.defineMethod('getScrollSize', () => ({ + width: document.body.scrollWidth, + height: document.body.scrollHeight, + })); + + switchboard.start(); } }); -console.info('[superset] embed page is ready to receive messages'); +log('embed page is ready to receive messages'); diff --git a/superset-frontend/tsconfig.json b/superset-frontend/tsconfig.json index a891fc62d7bfb..f67b47cdfe624 100644 --- a/superset-frontend/tsconfig.json +++ b/superset-frontend/tsconfig.json @@ -24,6 +24,7 @@ ], "@superset-ui/plugin-chart-*": ["./plugins/plugin-chart-*/src"], "@superset-ui/preset-chart-*": ["./plugins/preset-chart-*/src"], + "@superset-ui/switchboard": ["./packages/superset-ui-switchboard/src"] }, "types": [ "@emotion/react/types/css-prop",