El flujo estándar 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 extensión y la hace disponible para su uso en páginas.
# 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 fricción:
Para estos escenarios existe un patrón alternativo que he usado con éxito 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 ejecución. 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 usarías para subir un documento.
Si trabajas con Angular (que es mi caso habitual), el build de un Custom Element requiere configuración específica. 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 único 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 vía REST API. La automatización 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 típica de la URL es:
/documents/{groupId}/{folderId}/{nombreArchivo}/{uuid}
// o con friendly URL (formato de 7.4 U27+/GA27+):
/documents/d/{friendlyURLDelSitio}/{friendlyURLDelArchivo}
Recomiendo crear una carpeta dedicada en la Document Library, por ejemplo /Client Extensions/widgets/, para mantener organizados los assets.
Aquí está el núcleo del patrón. 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 configuración como data attributes si ya la tienes disponible en el JSON configurable del fragmento — sería redundante y expone datos innecesariamente en el DOM. Toda la configuración se inyecta desde el JavaScript del fragmento.
<div class="mi-widget-container">
<mi-widget-custom></mi-widget-custom>
</div>
JavaScript del fragmento:
Aquí es donde ocurre todo: se cargan los assets desde Document Library y se inyecta la configuración al Custom Element. Como el fragmento tiene acceso al objeto configuration (que viene del JSON configurable), pasamos los datos al CE vía 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 configuración viaja por JavaScript (propiedades del elemento), no queda expuesta en el HTML como data attributes. El Custom Element la recibe así:
// 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;
}
}
Configuración 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 parámetros que se pasan al Custom Element. Esto significa que un administrador de contenido puede cambiar la versión del widget simplemente actualizando la URL en la configuración del fragmento.
Ya vimos cómo inyectar configuración del JSON configurable vía propiedades del DOM. Pero hay más patrones de comunicación útiles según el caso:
Propiedades del elemento (recomendado): como mostramos arriba, el JS del fragmento asigna propiedades directamente al Custom Element. Es el patrón más limpio porque la configuración no queda expuesta en el HTML y se puede pasar objetos complejos, no solo strings.
Window Properties: para configuración global que aplica a todas las instancias del widget en la página, 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 comunicación bidireccional entre el fragmento (o la página 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: ¿cuándo usar Remote Applications y cuándo el patrón de fragmentos con Document Library?
Remote Applications conviene cuando:
Fragment-loaded CEs conviene cuando:
En la práctica, he usado el patrón de fragmentos como herramienta de desarrollo rápido y prototipado, y luego migrado a Remote Applications cuando el widget se estabiliza para producción.
Si decides usar este patrón 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 versión cacheada puede seguir sirviéndose. 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 producción), los archivos de la Document Library se sirven a través de él automáticamente. Verifica que los headers de cache del CDN sean compatibles con tu estrategia de actualización.
Rendimiento: cargar JS/CSS desde la Document Library agrega una petición HTTP extra comparado con un bundle integrado en la plataforma. Para mitigar esto, asegúrate de que los archivos estén minificados y considera usar preload hints en el fragmento.
Seguridad: los archivos en Document Library están sujetos al sistema de permisos de Liferay. Asegúrate de que los archivos JS/CSS sean accesibles para el rol Guest si el widget se muestra en páginas públicas.
Este patrón 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 integración con la plataforma.