Liferay Remote Apps in Rust!

Use the Yew framework to build WASM-based UI extensions for Liferay DXP 7.4

Caption

With the release of version 7.4 of Liferay’s Digital Experience Platform came a new feature called Remote Apps, which allow you display a remotely hosted web application directly in Liferay’s UI without having to deploy anything to your instance. Typically these apps are implemented using conventional UI frameworks like React or Angular, but theoretically they could be made using any web UI framework. I decided I wanted to put this to the test to see if I could get one working using Rust and the Yew framework, which is like React for Rust that compiles to WASM. Spoiler alert: you can! And now I’ll show you how.

(The code for this example can be found here)

Prerequisites

This tutorial assumes basic knowledge of Rust and JavaScript. If you don’t know either, I recommend reading up on them. After that, make sure you take care of the following:

Iframe Remote App

Liferay DXP 7.4 offers several different types of remote apps, but the two we’ll focus on here are Iframe apps and Custom Element apps since these two are UI-based and therefore suitable for Yew. Let’s start with Iframe because it’s easier to set up.

First, we need a Yew application. Clone or copy this sample app from the Yew repo. It’s just a simple counter app with an increment and decrement button. Let’s start it up to see what we have:

trunk serve --port 8081

The sample app


Getting this running in an Iframe is a piece of cake. Let’s open up our Liferay instance and navigate to Applications → Custom Apps → Remote Apps.

Accessing remote apps through the menu


Create a new remote app with the following details:

New Remote App

Notice that the URL is simply the host and port that Trunk is serving from.

Next, create a new page and put the remote app in it. I assume you already know how to do this. Here’s the result:

The application hosted in Liferay

It’s that simple!

By the way, when we ran Trunk serve, it generated a folder called dist with all our compiled WASM as well as some generated JavaScript and a modified index.html and transpiled css. Just host these files on a server and specify the URL in Liferay to have it ready for production.

Custom Element Remote Apps

As we saw, making an Iframe Remote App is a piece of cake; Custom Element remote apps on the other hand require significantly more work. However, for certain use cases the effort is worth it. After all, Iframe apps have some limitations. First, they can’t inherit css from the Liferay theme, making it difficult to maintain a consistent look and feel with the page that hosts them. Another problem is that they can’t interact with anything else on the page, meaning it can’t communicate with other widgets or interact with JavaScript APIs that Liferay exposes. Custom element remote apps provide all of these capabilities — you just need to put in some effort up front to get them. Let’s get started!

Dependencies and Settings

The main thing we need to do to get a custom element remote app working is, well, to define a custom element. The problem is, custom elements are defined in JavaScript, not Rust. How can we solve this problem?

Luckily, Rust has some crates to help us do just this. The main ones we need are the following three.

  1. web-sys, which provides bindings to web apis
  2. js-sys, which provides bindings to Javascript’s standard objects
  3. wasm-bindgen, which helps us interact with JavaScript
  4. custom-elements, a convenience tool to make it easier to create custom elements

Let’s clone our repo from before so we don’t lose our work, then update our Cargo.toml with our new dependencies as shown below:

[dependencies]
yew = "0.19"
custom-elements = "0.2.0"
gloo-console = "0.2"
js-sys = "0.3.58"
wasm-bindgen = "0.2.81"

[dependencies.web-sys]
version = "0.3.58"
features = [
  "Window",
  'Document',
]

We’ll also need to change the type of our project to “cdylib”, which tells Cargo to compile our project as a
dynamic library (for more info, check this documentation). Add this:

[lib]
crate-type = ["cdylib"]


Also, let’s rename our main.rs file to lib.rs since cdylib projects require lib.rs as their entry point. Next let’s make a new file called component.rs and move the current content of lib.rs there. This is just for organizational purposes.


Defining a Custom Element

Now let’s open lib.rs and write the code that creates our custom element. To do that, we need to first create a wrapper struct which will contain our app and also serve as our custom element. Let’s define that now:
 

// We'll use most of these imports later; for now, ignore them
mod component;
use component::Model;
use custom_elements::{inject_stylesheet, CustomElement};
use wasm_bindgen::prelude::wasm_bindgen;
use web_sys::HtmlElement;
use yew::AppHandle;

struct ComponentWrapper {
    handle: Option<AppHandle<Model>>,
}

impl ComponentWrapper {
    fn new() -> Self {
        Self { handle: None }
    }
}

impl Default for ComponentWrapper {
    fn default() -> Self {
        Self::new()
    }
}


Notice that our wrapper contains of Option of AppHandle<Model>. Remember that this Model is our counter application. AppHandle, on the other hand, is Yew’s container for an app. Also notice that we implemented the trait Default for our ComponentWrapper. This is necessary for CustomElement trait that we are about to implement to work properly.

Ok, now here’s the implementation for CustomElement:
 

impl CustomElement for ComponentWrapper {
    fn inject_children(&mut self, this: &HtmlElement) {
        let app = yew::start_app_in_element::<Model>(this.to_owned().into());
        self.handle = Some(app);
        inject_stylesheet(&this, "http://localhost:9000/main.css");
    }
    
    fn shadow() -> bool {
        false
    }
}

We have two functions here. The simple one is the shadow function, which simply returns false. This tells the CustomElement trait to generate a normal element, not one that has a shadow DOM. This is important because having a shadow DOM makes working with click events a terrible pain, so we need to set it to false.

The other is where the magic happens: inject_children. This function, as the name suggests, inserts child nodes into our custom element. It takes this as a parameter, which is actually JavaScript’s this bound in Rust. In this case, the value of this is an instance of HtmlElement.

In the first line of this function we use Yew’s start_app_in_element function to initialize the app. This takes as a parameter the element that you want to hold the app, which is this. Yew needs to own the value so we call to_owned, and Yew expect’s an instance of Element rather than HtmlElement, so we call into to convert it.

Next we set the value of handle on our ComponentWrapper struct to the app that we just initialized. Although we don’t strictly need this for our example, it is useful for the ComponentWrapper to hold onto the app so that it can pass information down to it programmatically. We won’t be using this feature, but it’s good practice anyway.

Last, we call the inject_stylesheet method to get our styles injected into our component. This isn’t the only way to handle styling our component, but for the moment this is most convenient. This method sets a link tag inside our component with the url pointing to our stylesheet. Notice that we need the complete path because this will live in our apps server, outside of Liferay. I’ve hard-coded the value here but it would be better to use environment variables for a production app. Also keep in mind a disadvantage of handling the stylesheet this way is that it will be loaded after the app is loaded, meaning that you’ll get a blip with no styling before the stylesheet arrives.

Initializing the Custom Element

The next step is to write a function that will initialize our custom element if it doesn’t exist already, and we need to export that function so we can call it from JavaScript. Here’s the function:

const ELEMENT_ID: &str = "liferay-rust-app";

#[wasm_bindgen]
pub fn run() {
    let window = web_sys::window().expect("window to be available.");
    let document = window.document().expect("document to be available");
    let element = document.get_element_by_id(ELEMENT_ID);
    if element.is_none() {
        ComponentWrapper::define(ELEMENT_ID);
    }
}


First we declare a constant to hold the name of our custom element. We then define the run function with the wasm_bindgen macro, which tells the compiler to make this executable from JavaScript. Next we get the window so we can then get the document so we can then try to get our custom element. If the element doesn’t exist, then we define it using our ComponentWrapper, which we made earlier.


JavaScript Entrypoint

Ok, so now we have the app itself finished, but we need to be able to launch the application. Before it was simple enough using Trunk, but we need a bit more control for a custom element app. Specifically, we need a JavaScript file that we can link to our remote app definition. To create this we need to use webpack and the wasm-pack-plugin. Let’s install those and the other plugins that we need.

npm install --save-dev @wasm-tool/wasm-pack-plugin css-loader html-webpack-plugin mini-css-extract-plugin postcss-loader sass sass-loader style-loader text-encoding webpack webpack-cli webpack-dev-server

Now, we need to create webpack.config.js and add the following. It’s out of scope to explain what all of this is doing, but if you have any questions feel free to shoot me a message.

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");module.exports = {
  entry: ["./index.js", "./index.scss"],
  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "postcss-loader",
          "sass-loader",
        ],
      },
    ],
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "index.js",
  },
  devServer: {
    port: 9000,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers":
        "X-Requested-With, content-type, Authorization",
    },
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "index.html",
    }),
    new WasmPackPlugin({
      crateDirectory: path.resolve(__dirname, "."),
    }),
    // Have this example work in Edge which doesn't ship `TextEncoder` or
    // `TextDecoder` at this time.
    new webpack.ProvidePlugin({
      TextDecoder: ["text-encoding", "TextDecoder"],
      TextEncoder: ["text-encoding", "TextEncoder"],
    }),
    new webpack.optimize.LimitChunkCountPlugin({
      maxChunks: 1,
    }),
    new MiniCssExtractPlugin(),
  ],
  mode: "development",
  experiments: {
    asyncWebAssembly: true,
  },
};


The one thing I do want to explain is the dev server configuration. Because our app will request some additional files after the main JavaScript file is loaded, we need to allow CORS or the requests will get blocked, which is what our dev server settings are doing. If you set up your own server to host your remote app, you’ll have to configure CORS to accept your Liferay instance as an accepted origin.


Make a JS Entrypoint

This step is pretty simple. In the project root, create a file called index.js and add the following code:

import "./index.scss";
const rust = import("./pkg");
rust.then((m) => m.run()).catch(console.error);


In the first line, we import our scss file. This is so webpack will pick it up and transpile it. After that we do an async import from the ./pkg folder. This folder doesn’t exist yet but when we run webpack build, the wasm-pack-plugin will first compile our Rust code into WASM and generate some supporting JavaScript files and put them in this folder. After that, webpack’s main transpile logic will run on our index.js file. By this time, the pkg folder exists with the necessary files. We use asynchronous import because this is required for WASM. Notice finally that we call m.run(). This run method is the same run function that we declared in lib.rs.

Now, if all is set up properly, we can run webpack serve to build our project and run it on the dev server. When it’s done, you’ll see the pkg folder and the dist folder generated. The content of the dist folder is what’s being served, so we can get at our JavaScript files at http://localhost:9000/index.js. Click the link to see if it’s there. If it is, we can set up the application on the Liferay side.


Protect Liferay Styling

To prevent our remote app’s styles from impacting Liferay’s theme styles, we need to provide a quick modification to the existing scss file that came with the sample app that we copied. Simply wrap everything in the name of our custom element so our styles stay contained within it:

liferay-rust-app {
  button {
    background-color: #008f53; /* Green */
    border: 0;
    color: white;
    padding: 14px 14px;
    text-align: center;
    font-size: 16px;
    margin: 2px 2px;
    width: 100px;
  }
  
  .counter {
      color: #008f53;
      font-size: 48px;
      text-align: center;
  }
  
  .footer {
      text-align: center;
      font-size: 12px;
  }
  
  .panel {
    display: flex;
    justify-content: center;
  }
}


Liferay Configuration

Go to Applications → Custom Apps → Remote Apps and create a new Remote App. Give it the following settings:

Custom element app settings


Notice that I didn’t include a css link. That’s because we’re already injecting as we saw in an earlier step. However, as I mentioned above, using inject_stylesheet will cause a delay between when the app is loaded and when the styles get loaded, leading to a blip when the app is displayed without styles. To solve that issue, you can simply link the file here and delete the call to inject_stylesheet.

Now let’s insert the app in a page and display it:

Custom element remote app with WASM

Now your app can inherit styles from the main theme style. Go ahead and try it. Go to component.rs and change the class for the buttons to “btn btn-primary” and see what happens:

Using theme styles


You can also interact with the rest of the page and even use the global Liferay object. These, however, require a bit more work on the Rust side and this post has already gone on long enough. If you are interested in how to do this, just give me a comment down below and I may write another post explaining how to do it. Until then, happy coding!

3
Blogs