Blogs
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:
-
(lines 1 to 3) copy assets generated from our frontend build process (usually defined as
"scripts": { "build": "…" }
in thepackage.json
file) frombuild/css/
to LUFFA’sstatic/
directory; -
(lines 5 to 8) use the
styles-*.css
file (usually a hashed file) to register the entry point of theglobalCSS
CX; -
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’spublic_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 insideclient-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
andwebContextPath
(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:
-
We assemble our frontend CX by copying assets
from
an output directory,into
thestatic/
one; -
The
static/
directory, and all its content, is copied to theliferay/caddy
’s/public_html/
, folder that will expose everything inside of it; -
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
tostatic
; -
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 atype="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.:
-
https://learn.liferay.com/w/dxp/liferay-development/client-extensions/working-with-client-extensions
-
https://learn.liferay.com/w/dxp/liferay-development/client-extensions/packaging-client-extensions
-
https://learn.liferay.com/w//liferay-cloud/reference/configuration-via-lcp-json
-
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules