Use Javascript Proxy for isolated context intercommunication
ClementVidal
Posted on April 5, 2021
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);
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 AsendMessage({name:"proccessData",payload:inputData});
// In context BonMessage(msg=>{switch (msg.name){case"proccessData":processData(msg.payload);}})
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 serviceconstservice={processData:(inputData)=>{}};consthandler={// This function get called each time an attribute of the proxy will be accessedget:function(target,prop,receiver){console.log(`Accessing ${prop}`);returntarget[prop];}};// Create a new proxy that will "proxy" access to the service object// using the handler "trap"constproxyService=newProxy(service,handler);constinputData=[];// This will log "Accessing processData"proxyService.processData(inputData);
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);
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 AconstdummyData=[1,4,5];constproxyService=createProxy("DataService");constprocessedData=awaitproxyService.processData(dummyData);
Let's build that step by step:
First let's implement a createProxy() method:
// In context AfunctioncreateProxy(hostName){// "proxied" objectconstproxyedObject={hostName:hostName};// Create the proxy objectreturnnewProxy(// "proxied" objectproxyedObject,// HandlersproxyHandlers);}
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 AconstproxyHandlers={get:(obj,methodName)=>{// Chrome runtime could try to call those method if the proxy object// is passed in a resolve or reject Promise functionif (methodName==="then"||methodName==="catch")returnundefined;// If accessed field effectivly exist on proxied object,// act as a noopif (obj[methodName]){returnobj[methodName];}// Otherwise create an anonymous function on the fly return (...args)=>{// Notice here that we pass the hostName defined// in the proxied objectreturnsendRequest(methodName,args,obj.hostName);};}}
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 callconstpendingRequest=newSet();letnextMessageId=0;functionsendRequest(methodName,args,hostName){returnnewPromise((resolve,reject)=>{constmessage={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 usingyourAPI.sendMessageToContextB(message);});}
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 Bconstservice={processData:(inputData)=>{}};registerHost("DataService",service);
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 BfunctionhandleRequestMessage(message){if (message.type==="request"){constrequest=message.request;// This is where the real implementation is calledexecuteHostMethod(request.hostName,request.methodName,request.args)// Build and send the response.then((returnValue)=>{constrpcMessage={id:message.id,type:"response",response:{returnValue:returnValue}};// This call will vary depending on which API you are usingyourAPI.sendMessageToContextA(rpcMessage);})// Or send error if host method throw an exception.catch((err)=>{constrpcMessage={id:message.id,type:"response",response:{returnValue:null,err:err.toString()}}// This call will vary depending on which API you are usingyourAPI.sendMessageToContextA(rpcMessage);});returntrue;}}// This call will vary depending on which API you are usingyourAPI.onMessageFromContextA(handleRequestMessage);
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...consthosts=newMap();functionregisterHost(hostName,host){hosts.set(hostName,host);}functionexecuteHostMethod(hostName,methodName,args){// Access the methodconsthost=hosts.get(hostName);if (!host){returnPromise.reject(`Invalid host name "${hostName}"`);}letmethod=host[methodName];// If requested method does not exist, reject.if (typeofmethod!=="function"){returnPromise.reject(`Invalid method name "${methodName}" on host "${hostName}"`);}try{// Call the implementation letreturnValue=method.apply(host,args);// If response is a promise, return it as it, otherwise// convert it to a promise.if (!returnValue){returnPromise.resolve();}if (typeofreturnValue.then!=="function"){returnPromise.resolve(returnValue);}returnreturnValue;}catch (err){returnPromise.reject(err);}}
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 AfunctionhandleResponseMessage(message){if (message.type==="response"){// Get the pending request matching this responseconstpendingRequest=pendingRequest.get(message.id);// Make sure we are handling response matching a pending requestif (!pendingRequest){return;}// Delete it from the pending request listpendingRequest.delete(message.id);// Resolve or reject the original promise returned from the rpc callconstresponse=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 promisependingRequest.reject(response.err);}else{// Otherwise resolve it with payload value.pendingRequest.resolve(response.returnValue);}}}// This call will vary depending on which API you are usingyourAPI.onMessageFromContextB(handleResponseMessage);
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: