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.
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.
El proceso de construccion de un portlet React tiene varias etapas que se ejecutan secuencialmente:
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"]
}
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.
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"
}
}
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:
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).
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.
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:
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.
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.
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.
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.
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.
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.
La pregunta inevitable es: cuando usar cada modelo. Aqui van las diferencias clave:
Portlets React con npm-bundler:
Liferay.authToken y Liferay.ThemeDisplayClient Extensions (Custom Element):
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.
Si decides migrar un portlet React existente a una Client Extension de tipo Custom Element, estos son los pasos principales:
main({ portletNamespace, contextPath, portletElementId, configuration }) por un Web Component estandar o un script que renderiza en un elemento customconfiguration.json a data-attributes del Custom Element o a una API de configuracion externaLiferay.authToken por autenticacion via OAuth2 Client Extensionclient-extension.yaml que registra tu aplicacion como Custom Element en LiferayLa 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.