In the course of my research encompassing extensibility and extensible UIs, I came upon a not very common approach to this problem which is referred to as remote rendering. This seems to be the solution behind Shopify and Stripe's extensibility UI implementations, two of the most comprehensive public solutions to this problem space.
Ther only public implementation i’ve found of this pattern was the remote-ui library by Shopify. In essence, this library allows to delegate the rendering, from a javascript sandbox (iframe or worker) to the main thread.
At the core of this solution is a technique called remote rendering which aims to separate the code that defines the UI from the code that renders it.
This allows you to run third-party code in a sandboxed environment that can then delegate rendering to a host application leveraging its native components, frameworks and design system.
Why would we want to do this?
The user experience of third-party extensions needs to be consistent with the host content
Avoid the bundle explosion issue of loading multiple separate applications with replicated assets across sandboxes
Extensions should run witihn a secure sandbox and their access to the host should be limited
Extensions shouldn’t be able to affect the host performance
Enable the same extensibility model across multiple platforms and even frameworks
Remote Rendering
The main concept of remote rendering is to allow an external script to define a UI within a secure sandbox and then transporting it to the host to render it natively. The third-party code is loaded and runs inside a JavaScript sandbox, and then transports a component tree to the main thread for rendering.
Transporting a component tree from the sandbox to the host is what ensures that rendering will happen using the host native elements.
Being able to safely transport UI from one context to another is the essential aspect of this pattern.
When thinking more deeply about this topic, we can reach the conclusion that most modern frameworks rely on what's called a component tree, a data structure which is then changed to fit that framework. For example, React works by recursively calling the 'createElement' function across a component tree to create its components.
import { createElement } from 'react';
function Greeting({ name }) {
return createElement(
'h1',
{ className: 'greeting' },
'Hello'
);
}
An example component tree would be
[
{
"id": "1",
"type": "Button",
"props": {
...
},
"children": [
{
"id": "0",
"text": "Log message in remote environment"
}
]
}
]
On a more lower level, the remote-ui library works by using two concepts a RemoteRoot
and a RemoteReceiver.
The remoteRoot
creates the local representation of the UI a developer will interact with from a remote context (eg: extension).
The RemoteReceiver
accepts the UI updates from the RemoteRoot
and reconstructs them into a component tree on the host. This tree can then be used to render the components to their native representation in the host. These entities communicate through a RPC abstraction that works across postmessage calls supporting the ability to pass around functions and event callbacks.
The RemoteRoot
uses this RPC layer to send UI updates as JSON messages to the host. The RemoteReceiver
gets the UI updates , reconstructs the component tree, and then renders it using native components.
How is this secure?
The sandbox, creates a layer of indirection between the host and the extension, it runs in an isolated environment (such as a web worker or iframe) and loads the extensions code within this secure environment. The extension code does not have access to anything ouside its sandbox, and even browser apis available inside the sandbox can be restricted.
When the extension is loaded, it will register itself as a callback function. After the extension finishes loading, the host can render it (that is, call the registered callback).
import { createEndpoint } from "@remote-ui/rpc";
import { createRemoteReceiver } from "@remote-ui/core";
const endpoint = createEndpoint(new Worker("./sandbox.js"));
const receiver = createRemoteReceiver();
endpoint.call.load("https://somewhere.com/extension.js").then(() =>
endpoint.call.render(receiver.receive, {
setTitle: (title) => document.title = title
});
);
When a third-party script defines an event callback such as a onClick
function on a button, despite the button being rendered on the host the callback execution is done on the sandbox through the RPC layer. This implementation is completely abstracted from the extension implementation through the RPC layer.
The Arguments passed to the render
function (from the host) provide it with everything it needs, either this is some context data, or the ability call a function that only exists on and is controlled by the host. The RPC abstraction will take care of delegating the execution.
This creates a very powerful bi-directional RPC abstraction that allows for functions to be passed across a postmessage interface while allowing for a very fine grained control over what the third-party code can do and access.
Resources
https://shopify.dev/docs/apps/admin/admin-actions-and-blocks
https://stripe.com/docs/stripe-apps
https://github.com/Shopify/remote-ui/tree/main
https://shopify.engineering/remote-rendering-ui-extensibility
https://portal.gitnation.org/contents/remote-rendering-with-web-workers