Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support MessageChannel in bridge.js #3566

Merged
merged 5 commits into from
Jul 21, 2021
Merged

Conversation

esanzgar
Copy link
Contributor

@esanzgar esanzgar commented Jul 6, 2021

Initially, I tried to if/else portions of the code to accommodate for
MessagePort, aiming to avoid duplication of the code (like I did for
shared/frame-rpc.js #3565). However, I found that, unlike
shared/frame-rpc.js, this resulted into a spaghetti-type of code, not
very understandable.

Then, I followed @robertknight suggestion:

  • Package up the data needed to create a channel for a window into one object, then make createChannel accept either that object or a MessagePort, and only have createChannel as the public API

  • Have two separate public methods, createChannelForWindow and createChannelForPort, which work with a Window and MessagePort respectively. Internally these could delegate to a shared helper to do most of the work

The two internal methods to support the current communication using Window and the new MessagePort, leads to clearer results, although some code is duplicated in both methods.

This PR will result on a reduction in code coverage, which will be fix
by #3598. (Edit: Additional unit tests have been added in this PR, so this comment no longer applies - @robertknight)

@codecov
Copy link

codecov bot commented Jul 6, 2021

Codecov Report

Merging #3566 (a3c2040) into master (cb38a10) will decrease coverage by 0.02%.
The diff coverage is 90.90%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3566      +/-   ##
==========================================
- Coverage   98.62%   98.60%   -0.03%     
==========================================
  Files         211      211              
  Lines        7723     7738      +15     
  Branches     1756     1758       +2     
==========================================
+ Hits         7617     7630      +13     
- Misses        106      108       +2     
Impacted Files Coverage Δ
src/sidebar/services/frame-sync.js 98.96% <50.00%> (-1.04%) ⬇️
src/shared/bridge.js 97.36% <94.44%> (-1.00%) ⬇️
src/annotator/cross-frame.js 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update cb38a10...a3c2040. Read the comment docs.

Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had a first look through this and left some feedback. Here are my high-level thoughts:

  • The code modernization/simplification and JSDoc improvements in shared/bridge.js and annotator/cross-frame.js looks great. It might be a good idea to split those changes off into a separate smaller PR which can be landed first.
  • The Bridge class provides two separate ways to create a channel that works with a MessagePort and there is some duplication between the code paths create a channel for a Window vs a MessagePort. I would prefer that there is only one public API to create a Bridge for a MessagePort and to avoid the duplication, since the only thing that changes depending on the message source is the RPC constructor arguments.
  • I suggested to omit the frame-type argument to createChannelFromPort for the moment, because when the time comes that we want to target messages at specific frames, we might want to revisit the use of Bridge altogether. The main function of Bridge is to send messages to multiple frames, so perhaps we don't need it at all if we're targeting a specific frame.
  • The existing Bridge tests are unit tests, although they don't use $imports.$mock like we do in more recent code, but the new ones are integration tests. Mixing and matching unit and integration tests in the same module creates confusion for subsequent developers because it becomes unclear which approach they should use for new tests. While I think we will want some integration tests for the inter-frame communication, the RPC interface is simple enough that I think we can mock it in bridge-test.js, but using $imports.$mock. It might be a good idea to create a separate PR that modernizes the mocking of RPC in bridge-test.js before making the functional change to support MessagePort.

@@ -153,6 +189,9 @@ export default class Bridge {
/**
* Unregister any callbacks registered with `on`.
*
* Attention: for this to have the intended effect, it needs to be called
* before `createChannel`, because at that point the methods are registered
* with the `RPC` class and there is no way to remove the listeners.
* @param {string} method
*/
off(method) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like we ever call this method anywhere. On that basis we could just remove it and the associated tests. This would remove the need for the caveat above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree 100%.

I also would suggest to rename on(...) to register(...). It makes clear that you have to:

  1. register the method
  2. create the channel
  3. call the method

@@ -89,7 +89,7 @@ export class CrossFrame {
* Subscribe to an event from the sidebar.
*
* @param {string} event
* @param {Function} callback
* @param {(...args: any[]) => void} callback
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These functions can pass a result callback as the last argument. Unfortunately I don't think there is a way to specify that at present (see microsoft/TypeScript#1360). This will have to do for now.

Copy link
Contributor Author

@esanzgar esanzgar Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a look too, but iI didn't find an easy way to add types to the arguments and callback.

However, an approach that in my opinion is better is to change the signature of the call method in FrameRPC and CrossFrame to allow for an object:

@@ -110,17 +110,18 @@ export class RPC {
    * If the final argument in `args` is a function, it is treated as a callback
    * which is invoked with the response.
    *
-   * @param {string} method
-   * @param {any[]} args
+   * @param {object} options
+   * @param {string} options.method
+   * @param {any[]} options.args
+   * @param {(...args: any[]) => void} [options.callback]
    */
-  call(method, ...args) {
+  call({ method, args, callback }) {
     if (this._destroyed) {
       return;
     }
     const seq = this._sequence++;
-    if (typeof args[args.length - 1] === 'function') {
-      this._callbacks[seq] = args[args.length - 1];
-      args = args.slice(0, -1);
+    if (callback) {
+      this._callbacks[seq] = callback;
     }
     this.destFrame.postMessage(
       {

If would make the use of it a bit more verbose, but also more explicit:

- bridge.call('sumMethod', 1, 2, 3, total => ....)`
+ bridge.call({method: 'sumMethod', args: [1, 2, 3],  callback: total => ....})`

Alternatively, this other variant may require a little bit less work refactoring old code:

- bridge.call('sumMethod', 1, 2, 3, total => ....)`
+ bridge.call('sumMethod', {args: [1, 2, 3],  callback: total => ....})`

@@ -10,8 +10,11 @@ import { RPC } from './frame-rpc';
*/
export default class Bridge {
constructor() {
/** @type {Array<RPC>} */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** @type {Array<RPC>} */
/** @type {RPC[]} */

Copy link
Contributor Author

@esanzgar esanzgar Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was debating about that, but I thought Array was more consistent with the onConnectListeners's type two lines after:

    /** @type {Array<(...args: any[]) => void>} */
    this.onConnectListeners = [];

@@ -21,7 +24,7 @@ export default class Bridge {
* This removes the event listeners for messages arriving from other windows.
*/
destroy() {
Array.from(this.links).map(link => link.channel.destroy());
this.links.forEach(channel => channel.destroy());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Array.from usage here was probably a hangover from semi-automated CoffeeScript => JS conversion. The decaffeinate tool that we used for some of this conversion added Array.from in some places to be conservative.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I makes sense.

}

/**
* Create a communication channel using `MessageChannel.MessagePort`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Create a communication channel using `MessageChannel.MessagePort`.
* Create a communication channel using a `MessagePort`

The previous wording suggests to me that MessagePort is a property of MessageChannel, but it is its own global.

});
clock.tick(1000);
} finally {
clock.restore();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this test is testing what happens in the event of a timeout I expected to see an error occur. I had to go back and look at the Bridge code to see that a null result indicates that a timeout occurred. This is surprising API design IMO, although I can see it is not new behavior on this branch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, it is surprising. Like you, I asked myself why not to reject the promise instead of returning a [null]. Rejecting the promise causes the channel to be closed, which is also surprising.

Comment on lines 417 to 421
try {
await reciprocalBridge.call('method1', 'params1');
} catch (err) {
assert.equal(err, error);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try {
await reciprocalBridge.call('method1', 'params1');
} catch (err) {
assert.equal(err, error);
}
let err;
try {
await reciprocalBridge.call('method1', 'params1');
} catch (err) {
err = e;
}
assert.equal(err, <expected error>)

If the assert is inside the catch then the test can incorrectly pass if the code fails to throw an error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.

try {
reciprocalBridge.destroy();

// It timeouts using bridge
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a rather roundabout way of testing that the channels were destroyed. The existing #destroy test just checks that the destroy method was called on the channel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is a roundabout, but it is exactly what a user of the Bridge API will expect, a timeout after 1s resulting on a [null] to be returned.

@@ -10,246 +10,476 @@ describe('shared/bridge', () => {
beforeEach(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing Bridge tests are unit tests that stub RPC.call. The new tests are integration tests that end up testing a lot of RPC-specific details. We generally try to avoid mixing unit and integration tests in the same module, as becomes unclear for developers making changes later which approach they should follow when adding a new test. Sometimes there might be cases where there is justification to have a test that deviates from the other tests in terms of its unit-ness. In that case it is good to call out the fact that these are an exception. For example with a specific "integration tests" describe block.

At the time when the Bridge tests were originally written I don't think we had a good mocking tool ($imports.$mock) which is probably why they create a real RPC and then stub call each time. This could be simplified to use $imports.$mock now. eg:

// Active channels
let channels = [];

class FakeRPC {
  constructor(sourceFrame, ...) {
    this.sourceFrame = sourceFrame;
    ...
    
    this.call = sinon.stub().resolves(...)
    channels.push(this);
  }
  
  destroy() {
    channels.splice(channels.indexOf(this), 1);
  }
}

...
$imports.$mock({ './frame-rpc': { RPC: FakeRPC }})

I think we are eventually going to want some integration tests for inter-frame messaging. Following the examples of src/{annotator, sidebar}/test/integration these could go in src/shared/test/integration/messaging-test.js.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is good to know about the integration tests.

Copy link
Contributor Author

@esanzgar esanzgar Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until this #3565, FrameRPC didn't have unit testing. I think the Bridge was supposed to unit test FrameRPC, however, because of the difficulty of creating two frames to communicate with each other, most of the methods in FrameRPC were mocked.

* @param {string} origin - The origin of the document in `source`.
* @param {string} token
* @return {RPC} - Channel for communicating with the window.
*
* @deprecated
*/
createChannel(source, origin, token) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are currently two different public APIs for creating a channel from a MessagePort - calling createChannel or calling createChannelFromPort directly. This seems redundant to me. I can suggest a few different ways to rectify this:

  • Package up the data needed to create a channel for a window into one object, then make createChannel accept either that object or a MessagePort, and only have createChannel as the public API
  • Have two separate public methods, createChannelForWindow and createChannelForPort, which work with a Window and MessagePort respectively. Internally these could delegate to a shared helper to do most of the work

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I can do something along those lines. The two implementations are 95% similar, that's why I thought it was OK to have both of them, since one of them is marked @deprecated and will be removed.

@esanzgar esanzgar force-pushed the support-messagechannel-frame-rpc branch 3 times, most recently from f6ae496 to a6bb101 Compare July 14, 2021 17:27
Base automatically changed from support-messagechannel-frame-rpc to master July 14, 2021 17:56
@esanzgar esanzgar force-pushed the support-messagechannel-bridge branch 2 times, most recently from 8886f60 to 32dbb68 Compare July 19, 2021 16:48
Initially, I tried to if/else portions of the code to accommodate for
`MessagePort`, aiming to avoid duplication of the code (like I did for
`shared/frame-rpc.js` #3565). However, I found that, unlike
`shared/frame-rpc.js`, this resulted into a spaghetti-type of code, not
very understandable.

Then, I decided to create two internal methods to support both the
current communication using `Window` and the new `MessagePort`. This in
my opinion leads to a clearer results, although some code is duplicated
in both methods.

This PR will result on a reduction in code coverage, which will be fix
by #3590.
@esanzgar esanzgar linked an issue Jul 19, 2021 that may be closed by this pull request
@esanzgar esanzgar requested a review from robertknight July 19, 2021 17:26
@esanzgar esanzgar force-pushed the support-messagechannel-bridge branch from 32dbb68 to 6dad72c Compare July 20, 2021 07:53
The need for this "testing only" code can be removed by improving the way
that we simulate the `connect` message being handled in tests.
@robertknight robertknight self-assigned this Jul 20, 2021
This argument was not used by any callers of `Bridge#onConnect` and
removing it eliminates a difference between creating a channel that uses
`Window.postMessage` vs one that uses `MessagePort`.
Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The revised PR looks better. The main feedback I have is regarding this comment:

This PR will result on a reduction in code coverage, which will be fix by #3598.

PR #3598 adds an integration test, but I don't see that as a reason not to have unit tests as well. Put another way, whether you run the integration tests or not shouldn't affect code coverage for most modules, with some rare exceptions.

Since you're away for the rest of this week, I've gone ahead and pushed a few additional unit tests as well as a couple of other minor changes based on my comments here. I will get this landed first thing tomorrow.

@@ -27,18 +27,83 @@ export default class Bridge {
this.links.forEach(channel => channel.destroy());
}

/**
* Deprecated - Remove after MessagePort conversion
* @typedef windowOptions
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @typedef windowOptions
* @typedef WindowOptions

Type names should use PascalCase. I'd probably have gone for something without an Options suffix since that tends to imply optional parameters. In this case the object is really a combination of a Window + origin. Maybe something like WindowTarget (as in fields needed to send messages to a particular window target)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit that keeps the name but just converts it to PascalCase.

/**
* Deprecated - Remove after MessagePort conversion
* @typedef windowOptions
* @prop {Window} source - The source window
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think using the term source is potentially a little confusing here because this window is both a source of messages that trigger on callbacks and a target of messages sent with call. Also when the RPC channel is constructed, the source is passed as the destFrameOrPort argument.

Naming this target would be misleading for the same reason and naming it window could cause it to be confused with the window global. So perhaps just revising the documentation to something like @prop {Window} source - The window to communicate with would be enough for now.

*/
createChannel(options) {
if (options instanceof MessagePort) {
return this._createChannelForPort(options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR will result on a reduction in code coverage, which will be fix by #3598.

PR #3566 adds integration tests, but I don't see a reason not to add unit tests here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit that adds a few extra tests to cover the new code paths.

@@ -53,7 +118,7 @@ export default class Bridge {
const connect = (_token, cb) => {
if (_token === token) {
cb();
ready();
ready(); // This is necessary for testing only.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This raised eyebrows. Why do we have testing-only code in this callback? Since there is a callback being invoked here it seems to me that the tests should try to leverage that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit that reworks the tests to avoid the need for the ready() callback here. This means that the MessagePort and Window code paths are now the same except that the Window code path checks the token before invoking the callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left the code as it was originally (since it will be removed anyway), but I added the comment, because like you said, it didn't look right.

@@ -162,7 +227,7 @@ export default class Bridge {
/**
* Add a listener to be called upon a new connection.
*
* @param {(channel: RPC, window: Window) => void} listener
* @param {(channel: RPC, window?: Window) => void} listener
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the callers of onConnect, none of them make use of the window argument, so we can just drop it.

  • The Guest class invokes onConnect indirectly (via CrossFrame) with no arguments
  • The FrameSync service invokes onConnect but only uses the first argument

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit that removes the window argument.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏼

@robertknight robertknight merged commit 42369e6 into master Jul 21, 2021
@robertknight robertknight deleted the support-messagechannel-bridge branch July 21, 2021 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support MessageChannel in bridge.js
2 participants