El flujo estandar para desplegar una Client Extension (CE) en Liferay DXP 7.4 es conocido: defines un archivo client-extension.yaml en tu proyecto, ejecutas el build, y despliegas el resultado en la plataforma. Liferay procesa el descriptor, registra la extension y la hace disponible para su uso en paginas.
# client-extension.yaml estandar
assemble:
- from: build/static
into: static
customElement:
cssURLs:
- css/main.*.css
htmlElementName: mi-widget-custom
instanceable: true
name: Mi Widget Custom
portletCategoryName: category.client-extensions
urls:
- js/main.*.js
Este flujo funciona bien para equipos con acceso al pipeline de CI/CD y al proceso de deployment de Liferay. Pero en proyectos enterprise reales, he encontrado situaciones donde este modelo genera friccion:
Para estos escenarios existe un patron alternativo que he usado con exito en varios proyectos: desplegar los assets de la CE en la Document Library y cargarlos desde fragmentos personalizados.
La idea es simple en concepto pero tiene matices importantes en la ejecucion. El flujo es:
La ventaja fundamental es que la Document Library es contenido gestionable. No necesitas acceso al servidor, no necesitas reiniciar nada, y puedes actualizar los archivos con el mismo flujo que usarias para subir un documento.
Si trabajas con Angular (que es mi caso habitual), el build de un Custom Element requiere configuracion especifica. Con Angular 17+ y la API de @angular/elements:
// main.ts - Angular Custom Element
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { MiWidgetComponent } from './app/mi-widget/mi-widget.component';
import { appConfig } from './app/app.config';
(async () => {
const app = await createApplication(appConfig);
const MiWidgetElement = createCustomElement(MiWidgetComponent, {
injector: app.injector,
});
customElements.define('mi-widget-custom', MiWidgetElement);
})();
El build genera archivos en dist/: un main.js (o con hash), un styles.css y posiblemente polyfills. Para simplificar el despliegue en Document Library, configura el build para generar un unico bundle sin hashes en el nombre:
{
"projects": {
"mi-widget": {
"architect": {
"build": {
"options": {
"outputHashing": "none",
"inlineStyleLanguage": "scss"
}
}
}
}
}
}
Puedes subir los archivos manualmente desde la interfaz de Liferay o automatizarlo via REST API. La automatizacion es recomendable para integrarlo en tu pipeline:
# Subir JS a Document Library via headless API
curl -X POST \
"https://tu-liferay.com/o/headless-delivery/v1.0/sites/{siteId}/documents" \
-H "Authorization: Basic $(echo -n 'user:pass' | base64)" \
-F "file=@dist/mi-widget/main.js" \
-F "document={\"title\":\"mi-widget-v1.2.js\",\"description\":\"Widget custom v1.2\"}"
Una vez subido, el archivo tiene una URL estable en la Document Library. La estructura tipica de la URL es:
/documents/{groupId}/{folderId}/{titulo}
// o con friendly URL:
/documents/d/{documentId}
Recomiendo crear una carpeta dedicada en la Document Library, por ejemplo /Client Extensions/widgets/, para mantener organizados los assets.
Aqui esta el nucleo del patron. Creas un fragmento en Liferay que hace tres cosas: carga el CSS, carga el JS, y monta el Custom Element.
HTML del fragmento:
El HTML solo monta el Custom Element. No es necesario exponer configuracion como data attributes si ya la tienes disponible en el JSON configurable del fragmento — seria redundante y expone datos innecesariamente en el DOM. Toda la configuracion se inyecta desde el JavaScript del fragmento.
<div class="mi-widget-container">
<mi-widget-custom></mi-widget-custom>
</div>
JavaScript del fragmento:
Aqui es donde ocurre todo: se cargan los assets desde Document Library y se inyecta la configuracion al Custom Element. Como el fragmento tiene acceso al objeto configuration (que viene del JSON configurable), pasamos los datos al CE via propiedades del DOM o un objeto global — no como data attributes en el HTML.
const fragmentElement = fragmentElement;
const configuration = configuration;
(function() {
const CSS_URL = configuration.cssUrl ||
'/documents/d/guest/mi-widget-styles-css';
const JS_URL = configuration.jsUrl ||
'/documents/d/guest/mi-widget-main-js';
// Cargar CSS si no esta ya cargado
if (!document.querySelector('link[href="' + CSS_URL + '"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = CSS_URL;
document.head.appendChild(link);
}
// Cargar JS si no esta ya cargado
if (!document.querySelector('script[src="' + JS_URL + '"]')) {
const script = document.createElement('script');
script.src = JS_URL;
script.type = 'module';
document.body.appendChild(script);
}
// Inyectar configuracion al Custom Element desde el configurable
const widget = fragmentElement.querySelector('mi-widget-custom');
if (widget) {
widget.apiUrl = configuration.apiUrl || '';
widget.widgetConfig = JSON.parse(configuration.widgetConfig || '{}');
}
})();
De esta forma la configuracion viaja por JavaScript (propiedades del elemento), no queda expuesta en el HTML como data attributes. El Custom Element la recibe asi:
// Dentro del componente Angular
@Component({ selector: 'mi-widget-custom' })
export class MiWidgetComponent implements OnInit {
private el = inject(ElementRef);
ngOnInit() {
// Leer propiedades inyectadas desde el JS del fragmento
const apiUrl = (this.el.nativeElement as any).apiUrl;
const config = (this.el.nativeElement as any).widgetConfig;
}
}
Configuracion del fragmento (JSON):
{
"fieldSets": [
{
"label": "Configuracion del Widget",
"fields": [
{
"name": "jsUrl",
"label": "URL del JavaScript",
"type": "text",
"dataType": "string",
"defaultValue": "/documents/d/guest/mi-widget-main-js"
},
{
"name": "cssUrl",
"label": "URL del CSS",
"type": "text",
"dataType": "string",
"defaultValue": "/documents/d/guest/mi-widget-styles-css"
},
{
"name": "apiUrl",
"label": "URL de la API",
"type": "text",
"dataType": "string",
"defaultValue": ""
},
{
"name": "widgetConfig",
"label": "Configuracion JSON",
"type": "text",
"dataType": "string",
"defaultValue": "{}"
}
]
}
]
}
El fragmento tiene campos configurables para las URLs de los assets y parametros que se pasan al Custom Element. Esto significa que un administrador de contenido puede cambiar la version del widget simplemente actualizando la URL en la configuracion del fragmento.
Ya vimos como inyectar configuracion del JSON configurable via propiedades del DOM. Pero hay mas patrones de comunicacion utiles segun el caso:
Propiedades del elemento (recomendado): como mostramos arriba, el JS del fragmento asigna propiedades directamente al Custom Element. Es el patron mas limpio porque la configuracion no queda expuesta en el HTML y se puede pasar objetos complejos, no solo strings.
Window Properties: para configuracion global que aplica a todas las instancias del widget en la pagina, o para inyectar datos del contexto de Liferay que el CE necesita.
// En el JavaScript del fragmento, antes de cargar el widget
window.__MI_WIDGET_CONFIG__ = {
theme: 'light',
language: Liferay.ThemeDisplay.getLanguageId(),
userId: Liferay.ThemeDisplay.getUserId(),
groupId: Liferay.ThemeDisplay.getScopeGroupId()
};
Custom Events: para comunicacion bidireccional entre el fragmento (o la pagina Liferay) y el Custom Element.
// El widget emite eventos
this.el.nativeElement.dispatchEvent(new CustomEvent('widget-loaded', {
bubbles: true,
detail: { version: '1.2.0', status: 'ready' }
}));
// El widget escucha eventos externos
window.addEventListener('liferay-navigation', (event: CustomEvent) => {
this.handleNavigation(event.detail);
});
Liferay 7.4 ofrece Remote Applications como mecanismo oficial para registrar Custom Elements externos. La pregunta es: cuando usar Remote Applications y cuando el patron de fragmentos con Document Library?
Remote Applications conviene cuando:
Fragment-loaded CEs conviene cuando:
En la practica, he usado el patron de fragmentos como herramienta de desarrollo rapido y prototipado, y luego migrado a Remote Applications cuando el widget se estabiliza para produccion.
Si decides usar este patron en un ambiente productivo, hay aspectos que no puedes ignorar:
Cache: la Document Library de Liferay aplica cache por defecto. Cuando actualizas un archivo, la version cacheada puede seguir sirviendose. Usa versionado en el nombre del archivo (mi-widget-v1.2.js) o agrega query parameters para cache busting (mi-widget.js?v=1.2).
CDN: si tienes un CDN frente a Liferay (lo cual es recomendable en produccion), los archivos de la Document Library se sirven a traves de el automaticamente. Verifica que los headers de cache del CDN sean compatibles con tu estrategia de actualizacion.
Rendimiento: cargar JS/CSS desde la Document Library agrega una peticion HTTP extra comparado con un bundle integrado en la plataforma. Para mitigar esto, asegurate de que los archivos esten minificados y considera usar preload hints en el fragmento.
Seguridad: los archivos en Document Library estan sujetos al sistema de permisos de Liferay. Asegurate de que los archivos JS/CSS sean accesibles para el rol Guest si el widget se muestra en paginas publicas.
Este patron no reemplaza el despliegue formal de Client Extensions, pero es una herramienta poderosa en el arsenal de un desarrollador Liferay que necesita flexibilidad sin sacrificar la integracion con la plataforma.