Skip to content

Commit d6d8967

Browse files
psybuzzstephanwlee
authored andcommitted
plugin: message channels for dynamic plugins
1 parent 24ed8eb commit d6d8967

File tree

4 files changed

+96
-79
lines changed

4 files changed

+96
-79
lines changed

tensorboard/components/plugin_util/message.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ See the License for the specific language governing permissions and
1313
limitations under the License.
1414
==============================================================================*/
1515

16+
/**
17+
* This file defines utilities shared by TensorBoard (plugin host) and the
18+
* dynamic plugin library, used by plugin authors.
19+
*/
20+
1621
export type PayloadType =
1722
| null
1823
| undefined
@@ -30,6 +35,7 @@ export interface Message {
3035
id: string;
3136
payload: PayloadType;
3237
error: string | null;
38+
isReply: boolean
3339
}
3440

3541
export type MessageType = string;
@@ -40,21 +46,15 @@ interface PromiseResolver {
4046
reject: (error: Error) => void;
4147
}
4248

43-
export abstract class IPC {
44-
private idPrefix: string;
49+
export class IPC {
4550
private id = 0;
4651
private readonly responseWaits = new Map<string, PromiseResolver>();
4752
private readonly listeners = new Map<MessageType, MessageCallback>();
53+
private readonly port: MessagePort;
4854

49-
constructor() {
50-
window.addEventListener('message', this.onMessage.bind(this));
51-
52-
// TODO(tensorboard-team): remove this by using MessageChannel.
53-
const randomArray = new Uint8Array(16);
54-
window.crypto.getRandomValues(randomArray);
55-
this.idPrefix = Array.from(randomArray)
56-
.map((int: number) => int.toString(16))
57-
.join('');
55+
constructor(port) {
56+
this.port = port;
57+
port.addEventListener('message', this.onMessage.bind(this));
5858
}
5959

6060
listen(type: MessageType, callback: MessageCallback) {
@@ -66,13 +66,12 @@ export abstract class IPC {
6666
}
6767

6868
private async onMessage(event: MessageEvent) {
69-
// There are instances where random browser extensions send messages.
70-
if (typeof event.data !== 'string') return;
71-
7269
const message = JSON.parse(event.data) as Message;
7370
const callback = this.listeners.get(message.type);
7471

75-
if (this.responseWaits.has(message.id)) {
72+
if (message.isReply) {
73+
if (!this.responseWaits.has(message.id))
74+
return;
7675
const {id, payload, error} = message;
7776
const {resolve, reject} = this.responseWaits.get(id);
7877
this.responseWaits.delete(id);
@@ -100,22 +99,22 @@ export abstract class IPC {
10099
id: message.id,
101100
payload,
102101
error,
102+
isReply: true,
103103
};
104-
this.postMessage(event.source, JSON.stringify(replyMessage));
104+
this.postMessage(replyMessage);
105105
}
106106

107-
private postMessage(targetWindow: Window, message: string) {
108-
targetWindow.postMessage(message, '*');
107+
private postMessage(message: Message) {
108+
this.port.postMessage(JSON.stringify(message));
109109
}
110110

111-
protected sendMessageToWindow(
112-
targetWindow: Window,
111+
sendMessage(
113112
type: MessageType,
114-
payload: PayloadType
113+
payload: PayloadType,
115114
): Promise<PayloadType> {
116-
const id = `${this.idPrefix}_${this.id++}`;
117-
const message: Message = {type, id, payload, error: null};
118-
this.postMessage(targetWindow, JSON.stringify(message));
115+
const id = `${this.id++}`;
116+
const message: Message = {type, id, payload, error: null, isReply: false};
117+
this.postMessage(message);
119118
return new Promise((resolve, reject) => {
120119
this.responseWaits.set(id, {resolve, reject});
121120
});

tensorboard/components/plugin_util/plugin-guest.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,25 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
See the License for the specific language governing permissions and
1313
limitations under the License.
1414
==============================================================================*/
15-
import {IPC, Message, MessageType, PayloadType} from './message.js';
16-
17-
class GuestIPC extends IPC {
18-
/**
19-
* payload must be JSON serializable.
20-
*/
21-
sendMessage(type: MessageType, payload: PayloadType): Promise<PayloadType> {
22-
return this.sendMessageToWindow(window.parent, type, payload);
23-
}
15+
import {IPC, Message} from './message.js';
16+
17+
/**
18+
* This code is part of a public bundle provided to plugin authors,
19+
* and runs within an IFrame to setup communication with TensorBoard's frame.
20+
*/
21+
if (!window.parent) {
22+
throw Error('This library must be run from within a loaded TensorBoard dynamic plugin.');
2423
}
2524

25+
const channel = new MessageChannel();
26+
const ipc = new IPC(channel.port1);
27+
channel.port1.start();
28+
29+
const VERSION = 'experimental';
30+
window.parent.postMessage(`${VERSION}.bootstrap`, '*', [channel.port2]);
31+
2632
// Only export for testability.
27-
export const _guestIPC = new GuestIPC();
33+
export const _guestIPC = ipc;
2834

2935
/**
3036
* Sends a message to the parent frame.
@@ -45,3 +51,7 @@ export const listen = _guestIPC.listen.bind(_guestIPC);
4551
* Unsubscribes a callback to a message.
4652
*/
4753
export const unlisten = _guestIPC.unlisten.bind(_guestIPC);
54+
55+
56+
// TODO: Register API for authors.
57+
// Methods should use 'ipc.sendMessage', Events should use 'ipc.listen'.

tensorboard/components/plugin_util/plugin-host.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,46 +12,63 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
See the License for the specific language governing permissions and
1313
limitations under the License.
1414
==============================================================================*/
15-
import {IPC, Message, MessageType, PayloadType} from './message.js';
16-
17-
class HostIPC extends IPC {
18-
sendMessage(
19-
iframe: HTMLIFrameElement,
20-
type: MessageType,
21-
payload: PayloadType
22-
): Promise<PayloadType> {
23-
return this.sendMessageToWindow(iframe.contentWindow, type, payload);
24-
}
25-
}
15+
import {IPC, MessageType, PayloadType} from './message.js';
16+
17+
const portIPCs = new Set<IPC>();
18+
const VERSION = 'experimental';
19+
const ipcToFrame = new WeakMap<IPC, HTMLIFrameElement>();
20+
21+
// The initial Window-level listener is needed to bootstrap only.
22+
// All further communication is done over MessagePorts.
23+
window.addEventListener('message', (event) => {
24+
if (event.data !== `${VERSION}.bootstrap`)
25+
return;
26+
const port = event.ports[0];
27+
if (!port)
28+
return;
29+
const frame = event.source ? event.source.frameElement : null;
30+
if (!frame)
31+
return;
2632

27-
const hostIPC = new HostIPC();
28-
const _listen = hostIPC.listen.bind(hostIPC);
29-
const _unlisten = hostIPC.unlisten.bind(hostIPC);
30-
const _sendMessage = hostIPC.sendMessage.bind(hostIPC);
33+
const portIPC = new IPC(port);
34+
portIPCs.add(portIPC);
35+
ipcToFrame.set(portIPC, frame as HTMLIFrameElement);
36+
port.start();
37+
38+
// TODO: install API.
39+
});
40+
41+
function _broadcast(
42+
type: MessageType,
43+
payload: PayloadType
44+
): Promise<PayloadType[]> {
45+
// Clean up disconnected iframes, since they won't respond.
46+
for (const ipc of portIPCs) {
47+
if (!ipcToFrame.get(ipc).isConnected) {
48+
portIPCs.delete(ipc);
49+
ipcToFrame.delete(ipc);
50+
}
51+
}
3152

32-
export const sendMessage = _sendMessage;
33-
export const listen = _listen;
34-
export const unlisten = _unlisten;
53+
const ipcs = [...portIPCs];
54+
const promises = ipcs.map(ipc => ipc.sendMessage(type, payload));
55+
return Promise.all(promises);
56+
}
3557

36-
// Export for testability.
37-
export const _hostIPC = hostIPC;
58+
export const broadcast = _broadcast;
3859

3960
namespace tf_plugin {
61+
4062
/**
41-
* Sends a message to the frame specified.
42-
* @return Promise that resolves with a payload from frame in response to the message.
63+
* Sends a message to all dynamic plugins. Individual plugins decide whether
64+
* or not to listen.
65+
* @return Promise that resolves with a list of payloads from each plugin's
66+
* response (or null) to the message.
4367
*
4468
* @example
45-
* const someList = await sendMessage('v1.some.type.guest.understands');
69+
* const someList = await broadcast('some.type.guest.understands');
4670
* // do fun things with someList.
4771
*/
48-
export const sendMessage = _sendMessage;
49-
/**
50-
* Subscribes to messages from specified frame of a type specified.
51-
*/
52-
export const listen = _listen;
53-
/**
54-
* Unsubscribes to messages from specified frame of a type specified.
55-
*/
56-
export const unlisten = _unlisten;
72+
export const broadcast = _broadcast;
73+
5774
} // namespace tf_plugin

tensorboard/components/plugin_util/test/plugin-test.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,15 @@ namespace tf_plugin.test {
5757
this.destListen = this.guestWindow.test.listen;
5858
this.destUnlisten = this.guestWindow.test.unlisten;
5959
this.srcSendMessage = (type, payload) => {
60-
return pluginHost.sendMessage(this.guestFrame, type, payload);
60+
return new Promise(async resolve => {
61+
const results = await pluginHost.broadcast(type, payload);
62+
resolve(results[0]);
63+
});
6164
};
6265
this.destPostMessageSpy = () =>
6366
this.sandbox.spy(this.guestWindow.test._guestIPC, 'postMessage');
6467
},
6568
},
66-
{
67-
spec: 'guest (src) to host (dest)',
68-
beforeEachFunc: function() {
69-
this.destListen = pluginHost.listen;
70-
this.destUnlisten = pluginHost.unlisten;
71-
this.srcSendMessage = (type, payload) => {
72-
return this.guestWindow.test.sendMessage(type, payload);
73-
};
74-
this.destPostMessageSpy = () =>
75-
this.sandbox.spy(pluginHost._hostIPC, 'postMessage');
76-
},
77-
},
7869
].forEach(({spec, beforeEachFunc}) => {
7970
describe(spec, () => {
8071
beforeEach(beforeEachFunc);

0 commit comments

Comments
 (0)