← Volver al blog
·8 min de lectura

Client Extensions via Document Library y Fragmentos en Liferay DXP

LiferayFrontendAngular

El despliegue clasico de Client Extensions

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:

  • El equipo de frontend itera rapido y no quiere esperar ciclos de deploy de la plataforma
  • Los administradores de contenido necesitan actualizar widgets sin involucrar al equipo de infraestructura
  • El ambiente de desarrollo no tiene configurado el despliegue automatico de CEs
  • Se necesita probar multiples versiones de un widget en paralelo

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.

El patron de Document Library

La idea es simple en concepto pero tiene matices importantes en la ejecucion. El flujo es:

  1. Compilas tu aplicacion frontend (Angular, React, Vue) como un Custom Element
  2. Obtienes los bundles finales: un archivo JS y un archivo CSS
  3. Subes estos archivos a la Document Library de Liferay
  4. Creas un fragmento que carga el JS/CSS desde las URLs de la Document Library
  5. El fragmento monta el Custom Element en su HTML

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.

Paso 1: Build del Custom Element

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"
          }
        }
      }
    }
  }
}

Paso 2: Subir a Document Library

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.

Paso 3: El fragmento que carga la CE

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.

Comunicacion entre fragmento y Custom Element

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);
});

Comparativa: Remote Applications vs Fragment-loaded CEs

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:

  • Necesitas que el widget aparezca en el panel de widgets como un portlet mas
  • Quieres gestion centralizada de todas las CEs registradas
  • El widget necesita integracion con el sistema de permisos de Liferay
  • Trabajas en un equipo grande con procesos de deploy establecidos

Fragment-loaded CEs conviene cuando:

  • Necesitas iteracion rapida sin ciclos de deploy formales
  • Los editores de contenido deben poder actualizar widgets autonomamente
  • Quieres versionar y hacer rollback facilmente (subir un archivo nuevo a Document Library)
  • El widget es especifico de una pagina o seccion, no reutilizable globalmente
  • Trabajas en un ambiente donde no tienes acceso administrativo para registrar Remote Apps

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.

Consideraciones 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.