An approach to handle third party SDK’s custom event systems in front end projects

Publié le

22/09/2021

Abstract (TL; DR;)

When working on frontend projects, a common way to deal with the asynchronicity of third party SDKs methods is to rely on callbacks or promises. Nonetheless, it is quite frequent that some libraries use their own event system to notify their consumers whenever an action has been successful or something unexpected happened. In such cases, we will see how easy it can be to slip on the mud of inadequate solutions, which either don't provide enough testability, or allow side-effects to break into other components, whereas they should always be kept isolated in dedicated layers. This article is an attempt to demonstrate how such cases can be properly handled, by forcing rebellious SDK’s methods to respect an observable contract, which will be more convenient to subscribe to and offers the advantage to never error out.

Context

The problem described in this article arose during the build phase of a reactive application which uses Redux as state manager, ReactiveX as reactive library and Redux observable as middleware between Redux and  ReactiveX. It therefore assumes some acquaintance with those technologies. You can refer to the excellent article of Sourabh Verma (1) for a great introduction on these topics.

Problem

Let’s imagine that an application needs to interact with a third-party library, which at least one method call is asynchronous, without however accepting a callback as an argument nor returning a promise (no luck). As an example, this is exactly how the Soundcloud widget API is working, and the point of the article is not to make a judgment on that behavior, but rather to try to find an elegant solution to deal with it. Looking at the documentation (2), it appears that this library manages its own event system and relies on a specific method to bind callbacks to these events:

const widget = SC.Widget("#my-iframe");
const PLAY_EVENT = "play";

widget.bind(PLAY_EVENT, () => {
  // do something once the play event has fired
});

// calling this method will fire the PLAY_EVENT callback above
widget.play();

The play action call and its callback being completely decoupled, it will be impossible to work with this SDK using a single classic flow of actions (i.e an user presses a button on the interface, that action is observed by an epic which calls the play method on the SDK and waits for the result before dispatching another action). At this stage, there is a clear indication that at least two distinct action flows will be required, which can be processed in many ways, and as a consequence increases the chances to create technical debt or to introduce other side effects in the application.

Naive approach

Under the hood, the Soundcloud event system exploits the window.postMessage API (3), which dispatches generic events if and only if a callback has been attached to a Soundcloud event using the bind method of the SDK. As an illustration, here is the message event dispatched throughout the application when the Soundcloud play event fires (i.e the song has been buffered and starts to play):

MessageEvent {
  // ...
  data: {
    "widgetId": "widget_1631090770163",
    "method": "play",
    "value": {...},
  }
  origin: "https://w.soundcloud.com"
  // ...
}

The first emerging idea would be to create an observable stream from that message event, and implement our business logic here, like in the following example:

// playEpic :: Epic -> Observable Action
const playEpic = () => fromEvent(window, "message").pipe(
  filter(event => event.data.method === "play"),
  // business logic related to the play event goes here
  map(theSongIsPlaying),
);

Although it's a perfectly working approach, it also has some annoying drawbacks. First, it introduces a tight coupling between the window object and the epic, and this is something to be avoided at all costs. Because of the global nature of the window, it's the less predictive object in a javascript application, even if it’s possible to gain control over it by injecting the window as a dependency of the epic.

Second, when it comes to testing, that kind of stream is hellish, because it requires to simulate that a specific event occurred on the window object to be able to test the logic. With modern testing frameworks it's completely doable, but my experience showed me that such tests are quite difficult to read, even more to maintain, and very likely to produce false positive results because they are highly dependent on the behavior of an object belonging to the outside scope of the epic. In the end, the gain to isolate side effects in epics would be greatly diminished because coupled to even less predictable objects, such as the window.

Last but not least, let’s remember that in order for messages to be posted to the window by the SDK, Soundclouds events first have to be binded to callbacks. Even if it would be possible to have some kind of a “dummy” epic to do this, like in the example below, something doesn't feel right at all doing so. Upcoming snippets assume the Soundcloud widget API both is injected as a dependency of all epics, and has been successfully initialized in a previous action flow (hence the INITIALIZED action):

// bindEventsEpic :: Epic -> Observable Action
const bindEventsEpic = (action$, _, { widgetApi }) => action$.pipe(
  ofType(INITIALIZED),
  tap(() => widgetApi.bind("play", () => {})),
  tap(() => widgetApi.bind("pause", () => {})),
  // ...
  map(eventsBinded),
);

It’s impossible to guess what is the responsibility of that epic at first sight. It does not have a direct role in playing a song or something related to the music business, it “binds stuff”. This is a great example of semantically insignificant, developer oriented code. For the three reasons detailed above, such a workflow can’t reasonably go into production.

Second approach

A better idea would be to directly use the binded callbacks to emit specific observable values, completely abstract ourselves from window messages and leave them to the SDK for its own use. A common mistake of newcomers to Rx would be to achieve that using Subjects, but it’s still valuable as an example to demonstrate how the road to hell is paved with good intentions. The first step is to create an observable subject:

// playSubject$ :: Observable
const playSubject$ = new Subject();

This playSubject$ is an observable and implements a next method, which will cause it to emit a value. That is, the SDK can be used to bind this next method to the callback of the Soundcloud play event:

// bindEventsEpic :: Epic -> Observable Action
const bindEventsEpic = (action$, _, { widgetApi }) => action$.pipe(
  ofType(INITIALIZE),
  tap(() => widgetApi.bind("play", playSubject$.next)),
  // ...
  map(eventsBinded),
);

Now every time the play event fires, the playSubject$ emits a little value which will notify its observers that something happened. This is a better approach for two reasons. First, it no longer requires to rely on messages posted over the window object, because the playEpic now directly observes the playSubject$:

// playEpic :: Epic -> Observable Action
const playEpic = () => playSubject$.pipe(
  // business logic related to the play event goes here
  map(theSongIsPlaying),
);

Second, and as a consequence, because the playSubject$ is a specialized stream (it will apparently only emit on the condition that a Soundcloud play event fires, whereas the window message event could be triggered by any other SDKs), the playEpic became more specific, less generic and thus the emitted values of the input stream have not to be filtered, like in the first example.

Apart from that dreadful bindEventsEpic still being in the picture, two major issues are to be solved. Epics are definitely not easier to test with this implementation, as they are now tightly coupled to a subject which is just as much part of the external world as the window object was. Worst of all, the playSubject$ can be used in any other part of the application, and emit a value from there, even if it’s completely unrelated to our business. Another potentially devastating side effect source has just been introduced in the application, and it would be an illusion to account on the common sense of developers to just “not use it that way” (if they can, they will). The implementation should be more defensive and better scoped.

Those two issues both came from a misunderstanding of what a subject is in the first place, as it has been perfectly nailed in an enlightening article written by Dave Sexton (4). To keep things short, subjects are a very specific type of observables. They are both observables and observers, which is inadequate in our situation. It can also lead to uncontrolled side effects, because in the end what is achieved by calling the next method over a subject is nothing else but mutation.

Final approach

To sum things up, solutions to this problem shares three common requirements: use the library the way it has been designed, without twisting it and “forcing” it to fit in the application architecture (if architecture choices are such boulders, maybe it's time to reconsider them); contain the use of that library to the internal scope of epics to avoid creating new side effects (like we did, trying to use a subject); to guarantee that last point, aim for pure epics which will moreover improve their testability.

The key concept here is isolation. By using subjects in the previous example, isolation is not achieved because the SDK is responsible to use the subject, whereas it should be the exact opposite. The SDK should be wrapped into an observable which will be responsible to use the SDK and to emit values on fixed conditions. To do so, the first step is to define our own way to create an observable from the SDK:

// fromWidget :: (Widget, String) -> Observable
const fromWidget = (widget, event) => 
  new Observable(subscriber => {
    try {
      widget.bind(
        event,
        () => subscriber.next(event),
      );
    } catch (err) {
      subscriber.error(err);
    }
  });

This creator function receives the Soundcloud widget API as a first argument, a Soundcloud event (a simple string) as second argument, and returns a brand new observable. Whether the API has been properly initialized is not the responsibility of that observable. The Observable constructor takes a single function as an argument, and exposes a subscriber, i.e. any observer susceptible to react to values emitted by the observable. Whenever this observable is subscribed to, it immediately executes that “initialization” function, in which the binding logic has been moved. Just like before, the widget binds the event to a callback. This callback will notify the subscriber by emitting a value each time an event matching the nature of the one provided as first argument fires.

How is any of this useful? First of all, by defining this fromWidget function, the dependency between the SDK and the observable has been inverted, as required by the desired solution. Second, this creator function provides a convenient abstraction to create observers from any Soundcloud event, and is therefore greatly reusable. Third, by mocking its dependencies, testing it now is a breeze. The tricky part is to correctly mock the widget API:

const WidgetMock = function () {
  const bindedEvents = {
    play: () => null,
  };
 
  this.bind = (event, callback) => bindedEvents[event] = callback;
 
  this.play = () => bindedEvents["play"]();
};

When the bind function is called over this mock, a new key/value pair is associated to the bindedEvents object that binds the given event with the given callback argument. That is, when the play function is called, the associated callback is executed. With this mock, testing the fromEvent function becomes trivial:

it("creates an observable from a soundcloud event", done => {
  const wm = new WidgetMock();
 
  // creates an observable that will emit every time the "play"
  //event occurs
  const fromWidget$ = fromWidget(wm, "play");
 
  // subscribe to the tested observable and assert on the result
  fromWidget$.subscribe(event => {
    expect(event).toBe("play");
    done();
  });
 
  // simulates a call to the play method on the widget so the
  // observable will emit and we can assert on the result
   wm.play();
 }, 1000);

With that capacity to create clean observable streams from the Soundcloud SDK, epics can be refactored and the real benefit of such implementation will clearly appear. A first epic is just responsible to call the play method on the SDK:

// playEpic :: Epic -> Observable Action
const playEpic = (action$, _, { widgetApi }) => action$.pipe(
  ofType(PLAY),
  tap(() => widgetApi.play()),
  // ...
);

The second epic is a little more complex, because it works with two nested observable streams:

// playingEpic :: Epic -> Observable Action
const playingEpic = (action$, _, { widgetApi }) => action$.pipe(
  ofType(INITIALIZED),
  switchMap(widget => fromWidget(widget, SC_PLAY).pipe(
    takeUntil(action$.pipe(ofType(CLEAN))),
  )),
  map(playing),
 );

Once the SDK has been successfully initialized, the fromWidget observable creator is used to create a second observable stream from the desired Soundcloud event. This new observable stream is switched to, i.e. the first observable stream does not matter anymore, as long as the second one has not completed. Consequence from that moment and until the CLEAN action is dispatched into the application (for example when the component using the Soundcloud logic unmounts): every time the Soundcloud play event fires, the observable created with the fromWidget utility emits, and the PLAYING action is dispatched in the application. By substituting the previous Subject implementation with an observable creator of our own, both epics now only depend on dynamically injected dependencies such as the widgetApi. Given a last mocking effort on those dependencies, testing is once more trivial. The following diagram gives an insight of the final action flow.

A. The player component is mounted in the app. The INITIALIZE action is dispatched. B. The SDK has been successfully initialized, the INITIALIZED action is dispatched. The playingEpic creates an observable stream from the Soundcloud play event and waits for a value to emit. C. In the interface, the user presses a play button. The PLAY action is dispatched. D. The playEpic calls the play method on the SDK. The observable created from that event emits a value once the song has buffered and starts playing. E. The PLAYING action is dispatched. The interface is updated (for example a pause button is displayed to replace the former play button). F. The player component unmounts. playEpic and playingEpic observable streams both complete.

Conclusion

Another approach would have been to exploit the useEffect hook of React components. But as it strongly tightens presentation components to more abstract and sometimes asynchronous logic, this solution often comes with issues regarding testability. One of my colleagues also suggested getting rid of the observable contortions and to delegate the responsibility of event binding to React lifecycle hooks.

The presented solution is one in millions, but represents a good compromise between expressive, business oriented code, testability, and working with lovely technologies by keeping a pragmatic mind and a technical debt as low as possible. As complex as the Observable pattern might be, it can be used in relatively simple ways.

References

  1. VERMA S. (n.d.). Building Reactive Apps with Redux, RxJS, and Redux-Observable in React Native. Toptal. toptal.com/react-native/react-redux-rxjs-tutorial

  2. Soundcloud. (n.d.). Widget API. developers.soundcloud.com/docs/api/html5-widget

  3. MDN. (n.d.). Window.postMessage. Web Docs. developer.mozilla.org/fr/docs/Web/API/Window/postMessage

  4. SEXTON D. (2013, June 22th). To Use Subject Or Not To Use Subject? Dave Sexton's Blog. davesexton.com/blog/post/To-Use-Subject-Or-Not-To-Use-Subject.aspx

Publié par

Joris
Joris Langlois


Commentaires