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.jsonfile) frombuild/css/to LUFFA’sstatic/directory;
-   (lines 5 to 8) use the styles-*.cssfile (usually a hashed file) to register the entry point of theglobalCSSCX;
- 
      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.propertiesThe 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-stylespart 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,projectNameandwebContextPath(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 froman output directory,intothestatic/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.tsAnd 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.cssSee 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/vitetostatic;
- 
      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: trueto 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 

