← Volver al blog
·11 min de lectura

Portlets React en Liferay DXP: desarrollo con liferay-npm-bundler

LiferayReactJava

El modelo anterior a las Client Extensions

Antes de que Liferay introdujera las Client Extensions en la version 7.4, la forma oficial de construir interfaces modernas con React dentro de Liferay DXP era a traves de portlets React empaquetados con liferay-npm-bundler. Este enfoque combinaba el ecosistema de React con el sistema de portlets de Java, produciendo un JAR desplegable que Liferay cargaba como cualquier otro modulo OSGi.

Aunque las Client Extensions son ahora el camino recomendado para nuevos proyectos, los portlets React con npm-bundler siguen siendo relevantes por varias razones: hay miles de proyectos en produccion que los usan, algunas capacidades de integracion profunda con Liferay aun no tienen equivalente en Client Extensions, y comprender su arquitectura te ayuda a entender por que las Client Extensions se disenaron como se disenaron.

En este articulo voy a detallar como funciona este modelo desde el pipeline de build hasta el despliegue, basandome en patrones reales que he implementado en proyectos enterprise con Liferay 7.1 a 7.4.

Que es liferay-npm-bundler

liferay-npm-bundler es una herramienta de empaquetado especifica de Liferay que toma una aplicacion JavaScript (tipicamente React) y la transforma en un bundle OSGi desplegable. Es conceptualmente similar a webpack, pero con un proposito muy especifico: producir un JAR que el framework OSGi de Liferay pueda cargar, resolver dependencias, y registrar como un portlet.

El bundler resuelve un problema fundamental: React y sus dependencias viven en el ecosistema npm, pero Liferay necesita bundles OSGi. El npm-bundler actua como puente entre estos dos mundos.

Pipeline de build

El proceso de construccion de un portlet React tiene varias etapas que se ejecutan secuencialmente:

1. Transpilacion con Babel

El codigo React (JSX, ES6+) se transpila a JavaScript compatible con los navegadores objetivo usando Babel. La configuracion tipica incluye @babel/preset-env y @babel/preset-react.

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

2. Empaquetado con npm-bundler

liferay-npm-bundler procesa el codigo transpilado y todas sus dependencias, generando una estructura de modulos AMD (Asynchronous Module Definition) que Liferay puede cargar bajo demanda. A diferencia de webpack que produce un unico archivo bundle, npm-bundler mantiene los modulos separados para que Liferay pueda gestionar la carga y el versionado.

3. Generacion del JAR

El resultado final es un archivo JAR que contiene el JavaScript empaquetado, los metadatos OSGi (MANIFEST.MF), y las definiciones del portlet. Este JAR se despliega en el directorio osgi/modules/ de Liferay.

El script de build en package.json tipicamente se ve asi:

{
  "scripts": {
    "build": "babel src/main/resources/META-INF/resources --out-dir build && liferay-npm-bundler"
  }
}

Entry point: la funcion main

Cada portlet React tiene un punto de entrada que Liferay invoca cuando el portlet se renderiza en una pagina. La firma de esta funcion es especifica y recibe parametros de runtime que Liferay inyecta:

export default function main({
  portletNamespace,
  contextPath,
  portletElementId,
  configuration
}) {
  ReactDOM.render(
    <App
      namespace={portletNamespace}
      contextPath={contextPath}
      configuration={configuration}
    />,
    document.getElementById(portletElementId)
  );
}

Cada parametro tiene un proposito especifico:

  • portletElementId: El ID del elemento DOM donde el portlet debe renderizarse. Liferay crea este elemento automaticamente en la pagina.
  • portletNamespace: Un prefijo unico para esta instancia del portlet. Es critico para evitar colisiones cuando hay multiples instancias del mismo portlet en una pagina. Todos los IDs de elementos internos deberian prefijarse con este namespace.
  • contextPath: La ruta base del portlet en el servidor. Se usa para construir URLs a recursos estaticos (imagenes, CSS) incluidos en el JAR.
  • configuration: Un objeto con los valores de configuracion que el administrador definio para esta instancia especifica del portlet.

Esta inyeccion de parametros es una diferencia clave respecto a las Client Extensions. En un Custom Element Client Extension, tu aplicacion React no recibe estos parametros directamente -- debe descubrirlos por otros medios (data-attributes, APIs de Liferay).

Configuracion por instancia: configuration.json

Una de las capacidades mas potentes de los portlets React es la configuracion por instancia. Defines un esquema en features/configuration.json que Liferay traduce a un formulario de configuracion en el panel de administracion del portlet:

{
  "system": {
    "category": "portal-settings",
    "name": "mi-portlet-config",
    "fields": {
      "apiEndpoint": {
        "dataType": "string",
        "type": "text",
        "label": "URL del API Backend",
        "default": "https://api.ejemplo.com"
      },
      "itemsPerPage": {
        "dataType": "int",
        "type": "text",
        "label": "Elementos por pagina",
        "default": "10"
      },
      "enableCache": {
        "dataType": "boolean",
        "type": "checkbox",
        "label": "Habilitar cache",
        "default": false
      }
    }
  }
}

Cuando un administrador coloca el portlet en una pagina y accede a su configuracion, ve un formulario generado automaticamente a partir de este esquema. Los valores que configura llegan al portlet React a traves del parametro configuration del entry point.

Esto permite tener el mismo portlet en multiples paginas con configuraciones diferentes -- por ejemplo, un portlet de listado de productos que en una pagina muestra 5 elementos y en otra muestra 20, conectandose a endpoints diferentes.

Configuracion de .npmbundlerrc

El archivo .npmbundlerrc en la raiz del proyecto controla el comportamiento del bundler:

{
  "config": {
    "output": "build/resources/main/META-INF/resources",
    "imports": {
      "liferay-npm-bundler-loader-css-loader": {
        "css-loader": ">=1.0.0"
      }
    }
  },
  "create-jar": {
    "output-dir": "build/libs",
    "web-context": "/mi-portlet-react"
  },
  "features": {
    "js-extender": true,
    "localization": "features/localization/Language",
    "configuration": "features/configuration.json"
  }
}

Los campos clave son:

  • output: Directorio donde se genera el JavaScript empaquetado
  • web-context: El contexto web bajo el cual Liferay sirve los recursos estaticos del portlet. Debe ser unico por portlet
  • js-extender: Habilita el extensor JavaScript que permite la carga de modulos AMD en Liferay
  • localization: Ruta a los archivos de internacionalizacion (Language_es.properties, Language_en.properties)
  • configuration: Ruta al esquema de configuracion por instancia

Dependencias y versiones pinneadas

Un aspecto critico del desarrollo con npm-bundler es el manejo de versiones de React. Liferay DXP incluye su propia copia de React en el portal (la version varia segun la actualizacion de Liferay), y los portlets pueden compartir esa instancia o incluir la suya propia.

La practica recomendada era pinnear la version de React para que coincidiera con la del portal:

{
  "dependencies": {
    "react": "16.8.6",
    "react-dom": "16.8.6"
  }
}

Si la version del portlet no coincide con la del portal, npm-bundler crea una copia aislada, lo que aumenta el tamano del bundle pero evita conflictos. Esto se conecta con la siguiente seccion.

Aislamiento de namespace

Uno de los problemas inherentes a tener multiples aplicaciones JavaScript en una misma pagina (que es exactamente lo que pasa cuando varios portlets coexisten) es la colision de dependencias. Si el portlet A necesita lodash 4.17 y el portlet B necesita lodash 3.10, ambas versiones deben coexistir sin interferirse.

liferay-npm-bundler resuelve esto prefijando cada dependencia con un identificador unico del portlet. Internamente, lodash no se registra como lodash sino como [email protected]. Este namespace automatico es transparente para el desarrollador -- en tu codigo sigues haciendo import _ from 'lodash' -- pero el bundler reescribe las referencias en build time.

Routing interno con HashRouter

Un portlet React vive dentro de una pagina de Liferay que tiene su propia URL y su propio sistema de navegacion. Esto significa que no puedes usar BrowserRouter de React Router, porque las URLs del navegador pertenecen a Liferay.

La solucion estandar es usar HashRouter, que gestiona la navegacion interna del portlet a traves del fragment de la URL (la parte despues del #):

import { HashRouter, Route, Switch } from 'react-router-dom';

function App({ namespace, configuration }) {
  return (
    <HashRouter>
      <Switch>
        <Route exact path="/" component={ListaProductos} />
        <Route path="/detalle/:id" component={DetalleProducto} />
        <Route path="/configuracion" component={Configuracion} />
      </Switch>
    </HashRouter>
  );
}

Esto permite que el portlet tenga navegacion interna compleja (listas, detalles, formularios multi-paso) sin interferir con la navegacion de Liferay. El usuario permanece en la misma pagina de Liferay mientras navega dentro del portlet.

Patron de capa de servicios

En proyectos enterprise, los portlets React tipicamente se comunican con APIs backend (ya sean las APIs headless de Liferay o servicios externos). Un patron que he visto funcionar bien es centralizar todas las llamadas HTTP en una capa de servicio que inyecta automaticamente los tokens de autenticacion:

const API_HEADERS = {
  'Content-Type': 'application/json',
  'Accept': 'application/json'
};

function getAuthHeaders() {
  return {
    ...API_HEADERS,
    'Authorization': `Bearer ${Liferay.authToken || ''}`
  };
}

export async function fetchFromLiferay(endpoint, options = {}) {
  const url = `${Liferay.ThemeDisplay.getPortalURL()}/o${endpoint}`;
  const response = await fetch(url, {
    ...options,
    headers: {
      ...getAuthHeaders(),
      ...options.headers
    }
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export async function fetchFromExternal(baseUrl, endpoint, options = {}) {
  const response = await fetch(`${baseUrl}${endpoint}`, {
    ...options,
    headers: {
      ...API_HEADERS,
      ...options.headers
    }
  });

  if (!response.ok) {
    throw new Error(`External API error: ${response.status}`);
  }

  return response.json();
}

Este patron centraliza la logica de autenticacion y manejo de errores, y distingue entre llamadas internas a Liferay (que usan Liferay.authToken) y llamadas a servicios externos.

CSS scoping

El CSS de un portlet React debe estar aislado para no afectar a otros portlets ni al tema de Liferay. La forma estandar es declarar la hoja de estilos en el descriptor del portlet:

<portlet>
  <portlet-name>mi-portlet-react</portlet-name>
  <header-portlet-css>/css/main.css</header-portlet-css>
  <instanceable>true</instanceable>
</portlet>

Dentro del CSS, todos los selectores deben estar scopeados al contenedor del portlet:

.mi-portlet-react .card {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 1rem;
}

.mi-portlet-react .btn-primary {
  background-color: var(--primary);
  color: white;
}

Este scoping manual es tedioso pero necesario. Alternativas como CSS Modules o styled-components tambien funcionan, pero requieren configuracion adicional en el pipeline de build.

Despliegue

El JAR resultante se copia al directorio osgi/modules/ de la instancia de Liferay. El framework OSGi detecta el nuevo bundle, resuelve sus dependencias, lo activa, y registra el portlet en el catalogo. Desde ese momento, los administradores pueden agregar el portlet a cualquier pagina desde el menu de widgets.

cp build/libs/mi-portlet-react.jar $LIFERAY_HOME/osgi/modules/

En entornos con CI/CD, este paso se automatiza: el pipeline compila el portlet, genera el JAR, y lo despliega al servidor via SSH, API de Liferay, o la consola de Liferay Cloud.

El hot deploy de OSGi permite actualizar el portlet sin reiniciar Liferay. El framework detecta que el JAR cambio, desactiva el bundle antiguo, y activa el nuevo. Las paginas que usan el portlet cargan la version actualizada automaticamente.

Portlets React vs Client Extensions

La pregunta inevitable es: cuando usar cada modelo. Aqui van las diferencias clave:

Portlets React con npm-bundler:

  • Se ejecutan dentro del proceso de Liferay (mismo JVM)
  • Reciben configuracion por instancia de forma nativa (configuration.json)
  • Acceso directo a Liferay.authToken y Liferay.ThemeDisplay
  • Gestionados por el ciclo de vida OSGi
  • Solo funcionan en On-Premise y PaaS (no en SaaS)
  • Requieren el toolchain especifico de Liferay (npm-bundler, Blade CLI)

Client Extensions (Custom Element):

  • Se ejecutan fuera del proceso de Liferay
  • Framework agnostico (React, Vue, Angular, o vanilla JS)
  • Configuracion via propiedades del Custom Element o APIs
  • Desplegables en cualquier modelo (On-Premise, PaaS, SaaS)
  • Pipeline de build estandar (Vite, webpack, lo que prefieras)
  • Mas simples de desarrollar y testear independientemente

Cuando los portlets React siguen siendo utiles

A pesar de que las Client Extensions son el futuro, hay escenarios donde los portlets React con npm-bundler siguen siendo la mejor opcion:

Aplicaciones enterprise complejas con configuracion por instancia: Si tu portlet necesita que cada instancia en cada pagina tenga configuracion diferente (diferentes endpoints, diferentes filtros, diferentes modos de visualizacion), el sistema de configuration.json resuelve esto de forma elegante sin codigo adicional.

Integracion profunda con autenticacion corporativa: Proyectos que integran flujos de autenticacion complejos (Azure AD con MSAL, SAML federado) donde el portlet necesita tokens de acceso gestionados por Liferay y pasados al frontend de forma transparente.

Proyectos existentes en Liferay 7.1 a 7.3: Si tu proyecto esta en una version anterior a 7.4 Update 36, las Client Extensions simplemente no estan disponibles. Los portlets React son tu unica opcion para interfaces modernas.

Equipos con toolchain establecido: Si tu equipo ya tiene experiencia con npm-bundler y un pipeline maduro, la migracion a Client Extensions puede no justificarse a corto plazo para modulos que funcionan correctamente.

Ruta de migracion hacia Client Extensions

Si decides migrar un portlet React existente a una Client Extension de tipo Custom Element, estos son los pasos principales:

  1. Extraer la aplicacion React del wrapper del portlet. Tu componente principal no cambia, pero el entry point si
  2. Reemplazar el entry point de main({ portletNamespace, contextPath, portletElementId, configuration }) por un Web Component estandar o un script que renderiza en un elemento custom
  3. Migrar la configuracion de configuration.json a data-attributes del Custom Element o a una API de configuracion externa
  4. Reemplazar Liferay.authToken por autenticacion via OAuth2 Client Extension
  5. Configurar un build estandar con Vite o webpack, eliminando la dependencia de npm-bundler
  6. Crear el descriptor client-extension.yaml que registra tu aplicacion como Custom Element en Liferay
  7. Testear de forma independiente -- una de las ventajas de las Client Extensions es que puedes ejecutar tu aplicacion React fuera de Liferay durante el desarrollo

La migracion no es trivial, especialmente si el portlet depende fuertemente de la configuracion por instancia o de objetos globales de Liferay. Pero el resultado es una aplicacion mas portable, mas facil de testear, y compatible con todos los modelos de despliegue de Liferay, incluido SaaS.