Saving Cloud (SaaS) resources for FE Client Extension

The reason behind the idea

In a world of Frontend Client Extensions deployed in SaaS environments, we can have a lot of fun and be creative to develop the best solutions our projects and customers need.

But due to the SaaS fact, it leads to extra costs involving the Cloud resources needed to make it work. Many different Frontend Client Extensions are commonly deployed individually, inside their on containers, giving life to a whole Cloud structure to make them available, and sometimes, just to serve a couple of CSS rules, or 3 to 5 lines of JavaScript to be used as a GlobalJS or FDS Cell Renderer and achieve the proper UI or specific behavior.

It can be very expensive and a blocker for some projects and customers. So the question is: how can we improve asset delivery so that we don't waste Cloud resources and still get a good night's sleep?

A brief review of Client Extension definition and LUFFA’s anatomy

Let’s consider this client-extensions/customer-global-styles/client-extension.yaml definition for a globalCSS CX:

assemble:
  - from: build/css
    into: static

customer-global-styles-a:
  name: Customer - Global Styles A
  type: globalCSS
  url: "style-*.css"

To assemble this CX, blade gw assemble will:

  1. (lines 1 to 3) copy assets generated from our frontend build process (usually defined as "scripts": { "build": "…" } in the package.json file) from build/css/ to LUFFA’s static/ directory;

  2. (lines 5 to 8) use the styles-*.css file (usually a hashed file) to register the entry point of the globalCSS CX;

  3. based on the configuration above, generate all the LUFFA files that we’ll see below.

The result of the blade’s assembling would be something like that:

build/
└── liferay-client-extension-build
    ├── customer-global-styles.client-extension-config.json
    ├── Dockerfile
    ├── LCP.json
    ├── static
    │   └── style-bbd1f5da.css
    └── WEB-INF
        └── liferay-plugin-package.properties

The important things for us here:

  • Dockerfile

    • FROM liferay/caddy:latest
      
      COPY static/ /public_html/
    • it just copy everything inside static/ to be served by the container’s public_html/ directory

 

  • customer-global-styles.client-extension-config.json

    • in this file’s name, the customer-global-styles part refers to the CX directory, the one inside client-extensions/

    • The content:

      {
        "com.liferay.client.extension.type.configuration.CETConfiguration~customer-global-styles-a" : {
          ...
          "baseURL" : "${portalURL}/o/customer-global-styles",
          ...
          "name" : "Customer - Global Styles A",
          "projectId" : "customerglobalstyles",
          "projectName" : "customer-global-styles",
          ...
          "type" : "globalCSS",
          "typeSettings" : [ "url=style-bbd1f5da.css" ],
          "webContextPath" : "/customer-global-styles"
        }
      }
      • In line 2 you can see the CX ID, derived from the ID defined in the YAML file (customer-global-styles-a).

      • The properties baseURL, projectId, projectName and webContextPath (lines 4, 7, 8 and 12) make reference to the directory name (customer-global-styles), not to the CX ID.

That’s interesting. So, if you try to access {portalURL}/o/customer-global-styles/style-bbd1f5da.css you’ll reach the asset. And again, the URL, doesn’t actually reflects the CX ID itself, but the project name, the directory that owns our client-extension.yaml file (client-extensions/customer-global-styles/).

Why does that matters?

You may have noticed it at this point, but let’s be clear:

  1. We assemble our frontend CX by copying assets from an output directory, into the static/ one;

  2. The static/ directory, and all its content, is copied to the liferay/caddy’s /public_html/, folder that will expose everything inside of it;

  3. And the CX’s LUFFA defines that the baseURL and projectId are based on the project folder.

We can conclude that:

We can build many different things into the output directory that will be used to assemble the CX, and everything that is build from the same client-extension.yaml will be part of the same projectId and served through the same baseURL .

With the proper frontend build configuration and proper YAML definition, we can have one code base that generates different kinds of assets, and all of them will be served by the same Cloud container, making it easier for you to save resources.

Think about deploying globalCSS, globalJS, customElement, fdsCellRenderer, fdsFilter, staticContent, themeSpritemap, jsImportMapsEntry, themeFavicon at once.

Show me the code

Alright, let’s see an example that can help us fully comprehend what we have talked.

We’ll use vite because it help us only worry about the config we are really interested in.

import { defineConfig } from "vite";
import { glob } from "glob";

import path from "node:path";
import { fileURLToPath } from "node:url";

export default defineConfig({

  // Here we define a base for the import resolution,
  // it should be the same name as the directory's,
  // just including the `/o/` at the beginning:
  base: "/o/customer-library",

  build: {
    lib: {

      // Here we define that all `export.ts(x)` are entry points,
      // so vite will resolve their dependencies and generate
      // the output to a similar file path structure, and also
      // managing the best way to properly build their dependencies:
      entry: Object.fromEntries(
        glob
          .sync("src/**/export.{ts,tsx}")
          .map((file) => [
            path.relative(
              "src",
              file.slice(0, file.length - path.extname(file).length),
            ),

            fileURLToPath(new URL(file, import.meta.url)),
          ]),
      ),

      // Here we say it should generate all
      // the library as EcmaScript Modules:
      formats: ["es"],
    },

    // And build it to the proper directory:
    outDir: `./build/vite`,

    rollupOptions: {
      output: {
        // And here we say the format of the generate
        // modules, in this case, EcmaScript Modules:
        format: "es",

        // Here we define the final file name for all the entry points:
        chunkFileNames: "[name]-[hash].js",

        // And also for all extra generated JS files
        // (chunks) that are used to keep shared code:
        entryFileNames: "[name]-[hash].js",

        // Any other kind of file (e.g.: CSS) should also be hashed:
        assetFileNames: "[name]-[hash].[ext]",
      },
    },
  },
});

So, let’s consider the following folder structure and analyze what will be generated (let’s say src/common/formatting.ts is used by all the others export.ts files):

client-extensions/customer-library
├── client-extension.yaml
├── index.html
├── package.json
├── public
│   ├── liferay-logo.png
│   └── liferay-logo.svg
├── src
│   ├── common
│   │   └── formatting.ts
│   ├── custom-element
│   │   ├── cx-a
│   │   │   ├── export.ts
│   │   │   └── style.css
│   │   └── cx-b
│   │       ├── export.ts
│   │       └── style.css
│   ├── fds-cell-renderer
│   │   ├── cx-a
│   │   │   └── export.ts
│   │   ├── cx-b
│   │   │   └── export.ts
│   │   └── cx-c
│   │       └── export.ts
│   ├── global-js
│   │   └── cx-a
│   │       └── export.ts
│   ├── js-import-map-entry
│   │   ├── cx-a
│   │   │   └── export.ts
│   │   ├── cx-b
│   │   │   └── export.ts
│   │   ├── cx-c
│   │   │   └── export.ts
│   │   └── cx-d
│   │       └── export.ts
│   ├── main.ts
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

And the output from the build process:

client-extensions/customer-library/build/vite
├── custom-element
│   ├── cx-a
│   │   └── export-b7af48de.js
│   └── cx-b
│       └── export-7b21e9a7.js
├── fds-cell-renderer
│   ├── cx-a
│   │   └── export-bab8d7c8.js
│   ├── cx-b
│   │   └── export-3d634c06.js
│   └── cx-c
│       └── export-9b03849f.js
├── global-js
│   └── cx-a
│       └── export-33b5cfd5.js
├── js-import-map-entry
│   ├── cx-a
│   │   └── export-821fabf7.js
│   ├── cx-b
│   │   └── export-96d4df29.js
│   ├── cx-c
│   │   └── export-e3193b0a.js
│   └── cx-d
│       └── export-73efd1bb.js
├── formatting-e0864a57.js
├── liferay-logo.png
├── liferay-logo.svg
└── style-0dec2a52.css

See how we have the paths of the entry points preserved (e.g.: custom-element/cx-a/export-b7af48de.js)? And the formatting-e0864a57.js, as a separated chunk, as it is shared between different modules? And also the unified style-0dec2a52.css, that is the result of the other style.css files (sure you may be able to customize its output)? And also the images, that were part of public/?

Now remember: everything inside this folder may be deployed/exposed within the final container.

And, finally, let’s take a look on the client-extension.yaml file:

assemble:
  - from: build/vite
    into: static

## JS Import Maps Entry

customer-library-importmap-a:
  bareSpecifier: "@customer/library/importmap-a"
  name: Customer Library - ImportMap A
  type: jsImportMapsEntry
  url: "js-import-map-entry/cx-a/export-*.js"

customer-library-importmap-b:
  bareSpecifier: "@customer/library/importmap-b"
  name: Customer Library - ImportMap B
  type: jsImportMapsEntry
  url: "js-import-map-entry/cx-b/export-*.js"

customer-library-importmap-c:
  bareSpecifier: "@customer/library/importmap-c"
  name: Customer Library - ImportMap C
  type: jsImportMapsEntry
  url: "js-import-map-entry/cx-c/export-*.js"

customer-library-importmap-d:
  bareSpecifier: "@customer/library/importmap-d"
  name: Customer Library - ImportMap D
  type: jsImportMapsEntry
  url: "js-import-map-entry/cx-d/export-*.js"

## Custom Element

customer-library-custom-element-a:
  portletCategoryName: category.client-extensions
  friendlyURLMapping: customer-library
  htmlElementName: cx-a
  instanceable: true
  useESM: true
  name: Customer Library - Custom Element A
  type: customElement
  urls:
    - "custom-element/cx-a/export-*.js"

customer-library-custom-element-b:
  portletCategoryName: category.client-extensions
  friendlyURLMapping: customer-library
  htmlElementName: cx-b
  instanceable: true
  useESM: true
  name: Customer Library - Custom Element B
  type: customElement
  urls:
    - "custom-element/cx-b/export-*.js"

## CSS

customer-library-styles:
  name: Customer Library - Styles
  type: globalCSS
  url: "style-*.css"

## JS

customer-library-global-a:
  name: Customer Library - Global JS A
  type: globalJS
  url: "global-js/cx-a/export-*.js"
  scriptElementAttributes:
    async: true
    type: "module"

## FDS Cell Renderer

customer-library-fds-cell-renderer-a:
  name: "Customer Library - FDS Cell Renderer A"
  type: fdsCellRenderer
  url: "fds-cell-renderer/cx-a/export-*.js"

customer-library-fds-cell-renderer-b:
  name: "Customer Library - FDS Cell Renderer B"
  type: fdsCellRenderer
  url: "fds-cell-renderer/cx-b/export-*.js"

customer-library-fds-cell-renderer-c:
  name: "Customer Library - FDS Cell Renderer C"
  type: fdsCellRenderer
  url: "fds-cell-renderer/cx-c/export-*.js"

A few things to recap here:

  • Assemble from build/vite to static;

  • All the URLs are with a * character (e.g.: export-*.js) because the generated files include the hash for better caching;

  • Custom elements have useESM: true to be loaded inside a type="module" script, allowing all the ESM thing to work;

  • And also Global JS has the scriptElementAttributes.type: "module" to take advantage of the ESM system.

Apart from that, everything is based on what we’ve discussed here: sharing bundle in the same container to save resources.

Knowing the possibility we have in hands for optimizing frontend extensions delivery, we unlock even more power to deploy more Frontend Client Extensions.

Things to think about

Container Memory

When you start serving many assets and getting many requests, you may need more memory for your container, so it will not be restarted. You can add a custom LCP.json to your project, at the same level of the client-extension.yaml, so it’ll be part of the LUFFA, and that new config will be applied as soon as it is deployed.

The folder structure

Of course you don’t need to follow the same folder structure, chose one that will better express your project’s architecture. The thing to keep in mind is having a pattern you can look for when defining entry points.

Share code base may be tough

Well, that’s true, in general, having a monorepo approach may get quite complex at some point. I haven’t test it yet, but maybe tools like Nx may help here.

Test your code

We’re using a modern frontend tooling here, you can add a test runner (actually vite has vitest) and add a prebuild step in your package.json, then add the important tests for your code and guarantee you’re not breaking anything that was properly tested and working before.

Why vite? What about webpack?

I particularly used webpack for year, and still maintain some projects using it. But it turns out vite gave me a better developer experience, with enough support for everything I needed in terms of bundling, and with less headaches.

 

Ref.: