DEV Community

ClementVidal
ClementVidal

Posted on • Updated on

Use Javascript Proxy for isolated context intercommunication

What are "isolated context intercommunication"

When writing a web app we spend our time invoking functions, that's what applications are made of:
Functions that call other functions.

While calling function is a trivial operation in most environments, it can become more complicated when dealing with isolated Javascript contexts.

Isolated Javascript contexts are independent Javascript execution context that lives aside each other.
Most of the time they are sandboxed, meaning you can't access objects, variables, or functions created in one context from the other one.

The only way to do "inter-context communication" is to use a dedicated API (provided by the underlying system) that allows to send messages from one side to the other.

There are more and more API that use that approach:

Once a message is sent from one side, you have to set up a message handler on the other side to do the effective processing and optionally return a value back to the sender.

The downside with that approach is that you are not "calling" a regular method anymore.

Instead of doing:

processData(inputData);
Enter fullscreen mode Exit fullscreen mode

You have to send a message using one of the previous API in one context and install a handler in the other context to handle that message:

// In context A
sendMessage({name: "proccessData", payload: inputData});
Enter fullscreen mode Exit fullscreen mode
// In context B
onMessage( msg => {
  switch (msg.name) {
     case "proccessData":
       processData( msg.payload );
  }
})
Enter fullscreen mode Exit fullscreen mode

Wouldn't it be nice if we could just call processData(inputData) from context A, get the implementation executed on context B, and have all the messaging logic hidden behind implementation details?

Well, that's what this article is about:
Implementing a remote procedure call (RPC) that will abstract the messaging layer.

How Es6 proxy can help us

If you don't know what Javascript proxy is you can have a look at this article

In short, proxy allows us to put custom logic that will get executed when accessing an object's attribute.

For example:

// Our exemple service
const service = { processData: (inputData) => { } };

const handler = {
  // This function get called each time an attribute of the proxy will be accessed
  get: function(target, prop, receiver) {
    console.log( `Accessing ${prop}` );
    return target[prop];
  }
};

// Create a new proxy that will "proxy" access to the service object
// using the handler "trap"
const proxyService = new Proxy( service, handler );

const inputData = [];
// This will log "Accessing processData"
proxyService.processData(inputData);

Enter fullscreen mode Exit fullscreen mode

Ok, now what's happen if we try to access an attribute that does not exist on the original object ?

// This will also log "Accessing analyzeData"
proxyService.analyzeData(inputData);
Enter fullscreen mode Exit fullscreen mode

Even if the attribute does not exist, the handler is still called.
Obviously, the function call will fail as return target[prop] will return undefined

We can take the benefit of that behavior to implement a generic remote procedure call mechanism.

Let's see how.

In the upcoming sections I'll refer to "context A" and "context B" as being 2 isolated Javascript contexts.
With an electron app, "context A" could be the render thread and "context B" the main thread.
With a WebExtension, "context A" could be a content script and "context B" the background script.

Implementing the remote procedure call system

The code presented below is only for "explanation" purposes, do not copy/paste it.
Check out the end of this article, I've provided a github repo with a fully working project.

The "send request part"

At the end of this section, you'll be able to use our remote procedure call API on the "sender side" this way:

// In context A

const dummyData = [1, 4, 5];
const proxyService = createProxy("DataService");
const processedData = await proxyService.processData(dummyData);
Enter fullscreen mode Exit fullscreen mode

Let's build that step by step:

First let's implement a createProxy() method:

// In context A

function createProxy(hostName) {
  // "proxied" object
  const proxyedObject = {
    hostName: hostName
  };

  // Create the proxy object
  return new Proxy(
    // "proxied" object
    proxyedObject,
    // Handlers
    proxyHandlers
  );
}
Enter fullscreen mode Exit fullscreen mode

Here the interesting thing is that the proxied object only has one attribute: hostName.
This hostName will be used in the handlers.

Now let's implement the handlers (or trap in es6 proxy terminology):

// In context A

const proxyHandlers = {
  get: (obj, methodName) => {

    // Chrome runtime could try to call those method if the proxy object
    // is passed in a resolve or reject Promise function
    if (methodName === "then" || methodName === "catch")
      return undefined;

    // If accessed field effectivly exist on proxied object,
    // act as a noop
    if (obj[methodName]) {
      return obj[methodName];
    }

    // Otherwise create an anonymous function on the fly 
    return (...args) => {
      // Notice here that we pass the hostName defined
      // in the proxied object
      return sendRequest(methodName, args, obj.hostName);
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The tricky part resides in the last few lines:
Any time we try to access a function that does not exist on the proxied object an anonymous function will be returned.

This anonymous function will pass 3 pieces of information to the sendRequest function:

  • The invoked method name
  • The parameters passed to that invoked method
  • The hostName

Here is the sendRequest() function:

// In context A

// This is a global map of ongoing remote function call
const  pendingRequest = new Set();
let nextMessageId = 0;

function sendRequest(methodName, args, hostName) {
  return new Promise((resolve, reject) => {

    const message = {
      id: nextMessageId++,
      type: "request",
      request: {
        hostName: hostName,
        methodName: methodName,
        args: args
      }
    };

    pendingRequest.set(message.id, {
        resolve: resolve,
        reject: reject,
        id: message.id,
        methodName: methodName,
        args: args
    });

    // This call will vary depending on which API you are using
    yourAPI.sendMessageToContextB(message);
  });
}
Enter fullscreen mode Exit fullscreen mode

As you can see the promise returned by sendRequest() is neither resolved nor rejected here.
That's why we keep references to its reject and resolve function inside the pendingRequest map as we'll use them later on.

The "process request part"

At the end of this section, you'll be able to register a host into the remote procedure system.
Once registered all methods available on the host will be callable from the other context using what we build in the previous section.

// In context B

const service = { processData: (inputData) => { } };
registerHost( "DataService", service );
Enter fullscreen mode Exit fullscreen mode

Ok, let's go back to the implementation:
Now that the function call is translated into a message flowing from one context to the other, we need to catch it in the other context, process it, and return the return value:

// In context B

function handleRequestMessage(message) {
  if (message.type === "request") {
    const request = message.request;
    // This is where the real implementation is called
    executeHostMethod(request.hostName, request.methodName, request.args)
      // Build and send the response
      .then((returnValue) => {
        const rpcMessage = {
          id: message.id,
          type: "response",
          response: {
            returnValue: returnValue
          }
        };

        // This call will vary depending on which API you are using
        yourAPI.sendMessageToContextA(rpcMessage);
      })
      // Or send error if host method throw an exception
      .catch((err) => {
        const rpcMessage = {
          id: message.id,
          type: "response",
          response: {
            returnValue: null,
            err: err.toString()
          }
        }

        // This call will vary depending on which API you are using
        yourAPI.sendMessageToContextA(rpcMessage);
      });
    return true;
  }
}

// This call will vary depending on which API you are using
yourAPI.onMessageFromContextA( handleRequestMessage );
Enter fullscreen mode Exit fullscreen mode

Here we register a message handler that will call the executeHostMethod() function and forward the result or any errors back to the other context.

Here is the implementation of the executeHostMethod():

// In context B

// We'll come back to it in a moment...
const hosts = new Map();

function registerHost( hostName, host ) {
   hosts.set( hostName, host );
}

function executeHostMethod(hostName, methodName, args) {

  // Access the method
  const host = hosts.get(hostName);
  if (!host) {
    return Promise.reject(`Invalid host name "${hostName}"`);
  }
  let method = host[methodName];

  // If requested method does not exist, reject.
  if (typeof method !== "function") {
    return Promise.reject(`Invalid method name "${methodName}" on host "${hostName}"`);
  }

  try {
    // Call the implementation 
    let returnValue = method.apply(host, args);

    // If response is a promise, return it as it, otherwise
    // convert it to a promise.
    if (!returnValue) {
      return Promise.resolve();
    }
    if (typeof returnValue.then !== "function") {
      return Promise.resolve(returnValue);
    }
    return returnValue;
  }
  catch (err) {
    return Promise.reject(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is where the hostName value is useful.
It's just a key that we use to access the "real" javascript instance of the object which holds the function to call.

We call that particular object the host and you can add such host using the registerHost() function.

The "process response part"

So now, the only thing left is to handle the response and resolve the promise on the "caller" side.

Here is the implementation:

// In context A

function handleResponseMessage(message) {
  if (message.type === "response") {
    // Get the pending request matching this response
    const pendingRequest = pendingRequest.get(message.id);

    // Make sure we are handling response matching a pending request
    if (!pendingRequest) {
      return;
    }

    // Delete it from the pending request list
    pendingRequest.delete(message.id);

    // Resolve or reject the original promise returned from the rpc call
    const response = message.response;
    // If an error was detected while sending the message,
    // reject the promise;
    if (response.err !== null) {
      // If the remote method failed to execute, reject the promise
      pendingRequest.reject(response.err);
    }
    else {
      // Otherwise resolve it with payload value.
      pendingRequest.resolve(response.returnValue);
    }
  }
}

// This call will vary depending on which API you are using
yourAPI.onMessageFromContextB( handleResponseMessage );
Enter fullscreen mode Exit fullscreen mode

Once we receive the response, we use the message id attribute that was copied between the request and the response to get the pending request object containing our reject() and resolve() method from the Promise created earlier.

So let's recap:

  • In context A:

    • We have created a proxy object on host "DataService".
    • We have called a method processData() on that proxy.
    • The call was translated into a message sent to the other context.
    • When the response from context B is received the Promise returned by processData() is resolved (or rejected).
  • In the context B:

    • We have registered a host called "DataService".
    • We have received the message in our handler.
    • The real implementation was called on the host.
    • The result value was ended back to the other context.

Final words

I've assembled all the code sample provided in this article in the following github repo:

It provides a full implementation of the remote procedure call system and demonstrates how it can be used with Web Workers.

Well...
That's it friends, I hope you enjoy reading that article.

I'll soon provide another one that will cover how to correctly handle Typescript typings with this system ;)

Happy coding !

Top comments (4)

Collapse
 
myshov profile image
Alexander Myshov • Edited

A great article! I would like to add that there is a library with similar idea for organization of communication with web worker github.com/GoogleChromeLabs/comlink

Collapse
 
clementvidal profile image
ClementVidal

Thanks Alexander, good to know !

Collapse
 
mqklin profile image
mqklin

Why do you need a proxy here? I believe you could do exactly the same without proxy: function processData(inputData) would return a promise that does sendMessage and resolves with a response from backend.

Collapse
 
clementvidal profile image
ClementVidal

Hell @mqklin , you say:
"return a promise that does sendMessage and resolves with a response from backend"
That's exactly what this code is doing, but instead of explicitly writing the piece of code that will do that in processData or any methods that need to be used that way, it do that for you, on your behalf so you don't have to care about it.

Does it answer your question?