Previsualización en Tiempo Real de Componentes React en Editores de Texto Enriquecido
En el ámbito de los editores de texto enriquecido y la creación de documentación para bibliotecas de componentes, la capacidad de ofrecer una previsualización interactiva en tiempo real es fundamental. Esta funcionalidad permite a los usuarios escribir código directamente en el documento y ver el resultado inmediatamente, lo que mejora significativamente la comprensión del uso del componente. Este artículo se centrará en la implementación de esta capacidad de previsualización dinámica para componentes React.
Contexto y Desafío
Muchas bibliotecas de componentes generan su documentación API a partir de archivos Markdown. En enfoques puramente estáticos, como los que usan algunos frameworks, los archivos Markdown se compilan en JSX mediante loaders y luego se incluyen en el proceso de empaquetado final. Esto da como resultado una documentación optimizada pero no interactiva. Por otro lado, bibliotecas como MUI permiten la edición en tiempo real de ejemplos de código, lo que indica un enfoque más dinámico.
Esta capacidad de "Playground" en pequeña escala es muy útil. Si bien Markdown es cómodo para desarrolladores, los editores de texto enriquecido ofrecen una experiencia más accesible para un público más amplio. La meta es integrar la previsualización dinámica de componentes en un editor de texto enriquecido, preferiblemente como un complemento cargado bajo demanda. Además, la necesidad de renderizar componentes dinámicamente surge en escenarios complejos, como la integración con plataformas OpenAPI para generar tablas o componentes complejos a partir de datos estructurados de forma inconsistente.
En un editor de texto enriquecido, la estructura de un bloque de previsualización es sencilla: un área para el código editable y otra para la previsualización. Los bloques de código pueden implementarse con herramientas como PrismJS o Lowlight, que analizan el código y aplican resaltado de sintaxis. La zona de previsualización, por su parte, debe ser un área donde el contenido renderizado se trate como un elemento incrustado o "Void" para evitar interferencias con el modelo del editor.
Ahora, el núcleo del problema: ¿cómo renderizar dinámicamente componentes React a partir de una cadena de texto? En JavaScript, las dos funciones principales para ejecutar código de una cadena son eval() y new Function(). Dada la necesidad de seguridad y control, new Function() es la opción preferente. eval() ejecuta el código en el ámbito actual, lo que le permite acceder y modificar variables, planteando riesgos de seguridad y efectos secundarios inesperados. En contraste, las funciones creadas con new Function() tienen su propio ámbito, accediendo solo al ámbito global y a los parámetros que se le pasen, lo que facilita el control del entorno de ejecución.
"use strict";
(() => {
let contador = 1;
eval("contador = 2;");
console.log(contador); // Salida: 2
})();
(() => {
let contador = 1;
const funcDinamica = new Function("contadorParam = 2;");
funcDinamica();
console.log(contador); // Salida: 1 (la variable original no se modifica)
})();
Con esta dirección clara, el siguiente paso es entender cómo renderizar código React en el navegador, ya que este no ejecuta JSX directamente.
Compiladores
Como se mencionó, los navegadores no entienden directamente el código JSX. Un componente como <Boton /> no tiene significado para el navegador hasta que se transforma. En el desarrollo de React, JSX se compila en llamadas a React.createElement (o a la función jsx de react/jsx-runtime en versiones posteriores). Por lo tanto, el primer paso es compilar la cadena de código React de JSX a una forma de llamada a función. Por ejemplo:
<Boton className="estilo-boton">
<div className="contenedor-hijo"></div>
</Boton>
// --->
React.createElement(Boton, {
className: "estilo-boton"
}, React.createElement("div", {
className: "contenedor-hijo"
}));
Babel
Babel es un compilador de JavaScript ampliamente utilizado para transformar código moderno a versiones compatibles con navegadores más antiguos. Para la compilación en el navegador, podemos usar babel-standalone, que incluye las funcionalidades principales de Babel y sus complementos más comunes. Aunque las versiones más recientes de @babel/standalone son más grandes, ofrecen soporte completo (incluyendo TypeScript y sintaxis como <></>).
Usar Babel es sencillo: se le pasa el código y una configuración de presets. El resultado es una cadena de código JavaScript compilado. Es importante notar que las versiones antiguas de babel-standalone podrían no soportar características modernas de JSX.
import { transform, BabelOptions, PluginObj } from "babel-standalone"; // Suponiendo importación directa
export const CONFIG_BABEL_BASE: BabelOptions = {
presets: ["stage-3", "react", "es2015"],
plugins: [],
};
/**
* Compila código JSX/JS utilizando Babel.
* @param code La cadena de código a compilar.
* @param options Opciones adicionales para Babel.
* @returns El código JavaScript compilado.
*/
export const compilarConBabel = function (code: string, options?: BabelOptions) {
const resultado = transform(code, { ...CONFIG_BABEL_BASE, ...options });
return resultado.code;
};
Un ejemplo de salida después de la compilación:
<Boton className="estilo-boton">
<div className="contenedor-hijo"></div>
</Boton>
// --->
"use strict";
React.createElement(
Boton,
{ className: "estilo-boton" },
React.createElement("div", { className: "contenedor-hijo" })
);
Una ventaja clave de Babel para la previsualización en vivo es la facilidad para registrar plugins. Esto nos permite aplicar reglas de seguridad durante la fase de análisis del código. Por ejemplo, podríamos permitir solo funciones nombradas como ComponentePrincipal o eliminar atributos como dangerouslySetInnerHTML para prevenir XSS. Sin embargo, estas medidas no son exhaustivas y la seguridad requiere una consideración más profunda.
import { PluginObj } from "babel-standalone";
export const limitarFuncionesBabel = (): PluginObj => {
return {
name: "plugin-limitar-codigo",
visitor: {
FunctionDeclaration(ruta) {
const nombreFuncion = ruta.node.id.name;
if (nombreFuncion !== "ComponentePrincipal") {
// throw new Error("Error: Nombres de función no permitidos.");
ruta.remove(); // Eliminar la función no permitida
}
},
JSXIdentifier(ruta) {
if (ruta.node.name === "dangerouslySetInnerHTML") {
// throw new Error("Error: Atributo no permitido.");
ruta.remove(); // Eliminar el atributo peligroso
}
},
},
};
};
compilarConBabel(codigoUsuario, { plugins: [limitarFuncionesBabel()] });
Para evaluar el rendimiento, podemos compilar un fragmento de código con 1000 componentes anidados. Los resultados muestran que Babel es razonablemente rápido para escenarios de playground a pequeña escala.
const generarCodigoPrueba = () => {
const FRAGMENTO = `
<Boton className="boton-prueba">
<div className="hijo-prueba"></div>
</Boton>
`;
return "<div>" + new Array(1000).fill(FRAGMENTO).join("") + "</div>";
};
console.time("babel");
const codigoLargo = generarCodigoPrueba();
const resultadoBabel = compilarConBabel(codigoLargo);
console.timeEnd("babel");
babel: 280.123 ms
SWC
SWC (Speedy Web Compiler) es un compilador de TypeScript/JavaScript escrito en Rust, conocido por su alta velocidad. Está diseñado para mejorar los tiempos de compilación en el desarrollo web, utilizando múltiples núcleos de CPU para procesar código en paralelo. rspack es un ejemplo de una herramienta que se basa en SWC.
Podemos usar swc-wasm, la versión WebAssembly de SWC, directamente en el navegador. La carga inicial de SWC debe ser asíncrona, pero una vez cargado, la transformación de código puede realizarse de forma síncrona. Aunque SWC permite plugins, estos deben implementarse en Rust, lo que implica una curva de aprendizaje.
import { init as initSwc, transformSync, Options as SWCOptions } from "@swc/wasm";
export const CONFIG_SWC_BASE: SWCOptions = {
jsc: {
parser: { syntax: "ecmascript", jsx: true },
},
};
let swcListo = false;
export const prepararSWC = async () => {
if (!swcListo) {
await initSwc();
swcListo = true;
}
};
/**
* Compila código JSX/JS utilizando SWC.
* @param code La cadena de código a compilar.
* @param options Opciones adicionales para SWC.
* @returns El código JavaScript compilado.
*/
export const compilarConSWC = async (code: string, options?: SWCOptions) => {
await prepararSWC(); // Asegurarse de que SWC esté inicializado
const resultado = transformSync(code, { ...CONFIG_SWC_BASE, ...options });
return resultado.code;
};
Salida de SWC:
<Boton className="estilo-boton">
<div className="contenedor-hijo"></div>
</Boton>
// --->
/*#__PURE__*/ React.createElement(Boton, {
className: "estilo-boton"
}, /*#__PURE__*/ React.createElement("div", {
className: "contenedor-hijo"
}));
El benchmark con los 1000 componentes demuestra que SWC es extremadamente rápido una vez cargado el módulo WebAssembly. La mayor parte del tiempo se consume en la carga inicial, pero las compilaciones subsiguientes son muy eficientes.
console.time("swc-con-preparacion");
await prepararSWC();
console.time("swc");
const codigoLargoSWC = generarCodigoPrueba();
const resultadoSWC = await compilarConSWC(codigoLargoSWC);
console.timeEnd("swc");
console.timeEnd("swc-con-preparacion");
swc: 50.123 ms
swc-con-preparacion: 750.456 ms (primera carga)
// Ejecuciones posteriores sin recargar WASM:
swc: 35.789 ms
swc-con-preparacion: 35.999 ms
Sucrase
Sucrase es una alternativa a Babel que se enfoca en la compilación ultrarrápida para el desarrollo, especializándose en extensiones de lenguaje no estándar como JSX, TypeScript y Flow. Su ámbito más reducido le permite adoptar una arquitectura de alto rendimiento. El analizador de Sucrase es una bifurcación del analizador de Babel, optimizado para un subconjunto de los problemas que resuelve Babel.
Sucrase es ideal para nuestro escenario de playground debido a su velocidad y su pequeño tamaño de paquete, cargándose directamente en el navegador. Sin embargo, a diferencia de Babel, Sucrase no ofrece un sistema de plugins para manipular el AST (Abstract Syntax Tree), por lo que las transformaciones específicas de seguridad deben manejarse con expresiones regulares o manipulaciones de cadena si son necesarias.
import { transform, Options as SucraseOptions } from "sucrase";
export const CONFIG_SUCRASE_BASE: SucraseOptions = {
transforms: ["jsx"],
production: true,
};
/**
* Compila código JSX/JS utilizando Sucrase.
* @param code La cadena de código a compilar.
* @param options Opciones adicionales para Sucrase.
* @returns El código JavaScript compilado.
*/
export const compilarConSucrase = (code: string, options?: SucraseOptions) => {
const resultado = transform(code, { ...CONFIG_SUCRASE_BASE, ...options });
return resultado.code;
};
Salida de Sucrase:
<Boton className="estilo-boton">
<div className="contenedor-hijo"></div>
</Boton>
// --->
React.createElement(Boton, { className: "estilo-boton",}
, React.createElement('div', { className: "contenedor-hijo",})
)
El benchmark con los 1000 componentes muestra que Sucrase es muy rápido, superando a Babel y con un rendimiento cercano a SWC sin la sobrecarga inicial de WebAssembly. Esto lo convierte en una excelente opción para previsualizaciones en vivo.
console.time("sucrase");
const codigoLargoSucrase = generarCodigoPrueba();
const resultadoSucrase = compilarConSucrase(codigoLargoSucrase);
console.timeEnd("sucrase");
sucrase: 55.678 ms
Construcción del Código
Una vez compilado el código JSX a llamadas a React.createElement, quedan dos desafíos: primero, cómo proporcionar las dependencias (como React y cualquier componente de biblioteca) a la función creada dinámicamente; segundo, cómo extraer la instancia del componente React resultante de esa ejecución.
Dependencias y with
Como se explicó, el constructor Function crea funciones en un ámbito global y no accede a las variables del ámbito de definición. Para pasar dependencias, una forma es declararlas como parámetros de la función. Sin embargo, para un control más fino y una gestión más limpia, la sentencia with puede ser útil.
La sentencia with (expression) statement extiende la cadena de ámbito para una sentencia. Las propiedades del objeto expression se añaden temporalmente al ámbito de la sentencia, permitiendo acceder a ellas sin prefijo. Si una propiedad no se encuentra en el objeto expression, la búsqueda continúa por la cadena de ámbito normal (hasta window). Aunque with está desaconsejado en modo estricto, en este contexto de sandbox puede ser controlado para nuestro beneficio.
const suma = new Function('a', 'b', 'return a + b');
console.log(suma(1, 2)); // Salida: 3
// Ejemplo de 'with'
with (Math) {
console.log(PI); // Salida: 3.1415926...
console.log(cos(PI)); // Salida: -1
console.log(sin(PI / 2)); // Salida: 1
}
Para pasar las dependencias, podríamos añadir todas las propiedades de un objeto contexto como parámetros de la función, o usar with. El uso de with con un objeto contexto es más compacto:
const contexto = {
React: "Objeto React",
Boton: "Objeto Boton",
console: console,
};
// Con parámetros directos:
const codigoParam = `
console.log(React, Boton);
`;
const funcParam = new Function(...Object.keys(contexto), codigoParam.trim());
funcParam(...Object.values(contexto)); // Salida: Objeto React Objeto Boton
// Con 'with':
const codigoWith = `
with(contexto){
console.log(React, Boton);
}
`;
const funcWith = new Function("contexto", codigoWith.trim());
funcWith(contexto); // Salida: Objeto React Objeto Boton
La estrategia with(contexto) es preferible ya que centraliza las dependencias. Esto también es clave para la seguridad: podemos controlar el acceso a objetos globales. Si un objeto no se encuentra en contexto, la búsqueda continuará. Para bloquear el acceso a window, podemos usar un Proxy para interceptar las propiedades de contexto y solo permitir el acceso a una lista blanca (whitelist) de elementos, devolviendo null para otros. Esto se detallará más en la sección de seguridad.
const contextoSeguro = {
React: "Objeto React",
Boton: "Objeto Boton",
console: console,
};
const permitidas = [...Object.keys(contextoSeguro)];
const proxyContexto = new Proxy(contextoSeguro, {
get(target, prop) {
if (permitidas.includes(prop as string)) {
return (target as any)[prop];
} else {
return null; // Bloquear acceso a elementos no permitidos
}
},
has: () => true // Importante para 'with': siempre reportar que la propiedad existe
});
const codigoConProxy = `
with(contexto){
console.log(React, Boton, window, document, setTimeout);
}
`;
const funcConProxy = new Function("contexto", codigoConProxy.trim());
funcConProxy(proxyContexto); // Salida: Objeto React Objeto Boton null null null
JSX y Funciones
Ahora, el objetivo es obtener una instancia de componente React de la cadena compilada. Si simplemente usamos new Function("contexto", "with(contexto) { React.createElement(Boton, null) }"), no se obtendría un valor de retorno. Necesitamos encapsular la expresión JSX compilada en una sentencia return.
import React from 'react';
import ReactDOM from 'react-dom';
interface SandboxContext {
[key: string]: any;
React: typeof React;
ReactDOM?: typeof ReactDOM;
_REACT_BRIDGE_?: Record<string unknown="">; // Para extraer el componente
}
/**
* Renderiza un componente React inyectando un 'return' implícito.
* Requiere que el código no contenga sentencias como 'import'.
* @param code Código React compilado.
* @param context El objeto sandbox con dependencias.
* @returns La instancia del componente React.
*/
export const crearComponenteDirecto = (code: string, context: SandboxContext) => {
// Envuelve el código con un "return" para obtener la instancia del componente.
const fn = new Function("contexto", `with(contexto) { return (${code.trim()})}`);
return fn(contexto);
};
</string>
Esta aproximación tiene limitaciones: el código de usuario no puede contener sentencias como import, ni tampoco declaraciones de funciones fuera de la expresión principal si se usa Babel, ya que intentaría retornar fuera de una función. Es más robusto usar un "puente" para extraer el componente.
Una estrategia más flexible es inyectar una variable en el contexto que actúe como un puente para pasar el componente fuera del ámbito de la función. Generamos un ID único y asignamos el resultado de la compilación a una propiedad de este objeto puente. Luego, la función principal lo recupera.
import React from 'react';
import { v4 as uuidv4 } from 'uuid'; // Para generar IDs únicos
interface SandboxContext {
[key: string]: any;
React: typeof React;
_REACT_BRIDGE_?: Record<string unknown="">; // Para extraer el componente
}
/**
* Crea una instancia de componente React utilizando un objeto puente para la extracción.
* @param compiledCode Código React compilado (ej. 'React.createElement(Boton, null)').
* @param context El objeto sandbox con dependencias.
* @returns La instancia del componente React.
*/
export const crearComponenteConPuente = (compiledCode: string, context: SandboxContext) => {
const idTemporal = uuidv4();
context._REACT_BRIDGE_ = {}; // Asegura que el puente exista
const puente = context._REACT_BRIDGE_ as Record<string unknown="">;
const codigoEnvuelto = `
with(contexto) {
_REACT_BRIDGE_["${idTemporal}"] = (${compiledCode.trim()});
}
`;
const funcGeneradora = new Function("contexto", codigoEnvuelto);
funcGeneradora(context); // Ejecutar la función para asignar el componente al puente
return puente[idTemporal]; // Recuperar el componente
};
</string></string>
Ejemplo de código compilado que usa el puente:
/*#__PURE__*/React.createElement(Boton, {
__self: void 0,
__source: {
fileName: "/sample.tsx",
lineNumber: 1,
columnNumber: 26
}
});
// --->
// Código final que se ejecuta dentro de new Function:
with(contexto) {
_REACT_BRIDGE_["id-unica-xyz"] = /*#__PURE__*/React.createElement(Boton, {
__self: void 0,
__source: {
fileName: "/sample.tsx",
lineNumber: 1,
columnNumber: 26
}
});
}
Para una mayor flexibilidad, se puede establecer una convención, como que el usuario defina un componente llamado App. El código generado entonces instanciaría React.createElement(App). Esto permite al usuario utilizar Hooks como useEffect, ya que App sería una función completa.
const App = () => {
React.useEffect(() => {
console.log("Efecto secundario ejecutado");
}, []);
return /*#__PURE__*/React.createElement(Boton, null);
};
// Código inyectado para extraer el componente:
_REACT_BRIDGE_["id-unica-xyz"] = React.createElement(App);
Renderizado del Componente
Con la instancia del componente React obtenida, el siguiente paso es renderizarla en la interfaz de usuario.
Renderizado Cliente (ReactDOM)
La forma más común de renderizar componentes React en el navegador es con ReactDOM.render (o ReactDOM.createRoot().render en React 18+). Identificamos un elemento DOM (por ejemplo, un div) donde montar el componente y simplemente lo pasamos a ReactDOM.render.
import React from 'react';
import ReactDOM from 'react-dom';
import { Button as Boton } from '@arco-design/web-react'; // Suponiendo una biblioteca de UI
// ... funciones de compilación y creación de componentes ...
const codigoUsuario = `
<Boton type='primary' onClick={() => alert('¡Hola!')}>Primario</Boton>
`;
const elementoContenedor = document.getElementById('contenedor-preview'); // Un div en el DOM
const dependenciasSandbox = {
React: React,
ReactDOM: ReactDOM,
Boton: Boton,
console: console,
alert: alert,
};
// Asumiendo que 'crearComponenteConPuente' y 'compilarConSucrase' están definidos
const codigoCompilado = compilarConSucrase(codigoUsuario);
const ComponenteReactDinamico = crearComponenteConPuente(codigoCompilado, dependenciasSandbox) as JSX.Element;
if (elementoContenedor) {
ReactDOM.render(ComponenteReactDinamico, elementoContenedor);
}
Alternativamente, podríamos permitir que el usuario invoque una función render() personalizada en su código, que internamente llamaría a ReactDOM.render, pero restringiéndolo a un contenedor DOM específico. Esto le da más control al usuario sin ceder el control total del ReactDOM.
const codigoUsuarioConRender = `
render(<Boton type='primary' onClick={() => alert('¡Hola desde Render!')}>Botón Personalizado</Boton>);
`;
const funcionRenderPersonalizada = (elemento: JSX.Element) => {
const contenedor = document.getElementById('contenedor-preview');
if (contenedor) ReactDOM.render(elemento, contenedor);
};
const dependenciasConRender = {
React: React,
Boton: Boton,
console: console,
alert: alert,
render: funcionRenderPersonalizada, // Inyectamos nuestra función render
};
const codigoCompiladoConRender = compilarConSucrase(codigoUsuarioConRender);
// En este caso, la ejecución del puente no retorna un componente, sino que ejecuta la función render.
crearComponenteConPuente(codigoCompiladoConRender, dependenciasConRender);
Renderizado en Servidor (SSR)
La previsualización de componentes en editores Markdown a menudo requiere soporte para SSR. Después de compilar el código dinámicamente, podemos usar ReactDOMServer.renderToString (que incluye atributos data-reactid para que React pueda "hidratar" sin volver a renderizar el DOM) o ReactDOMServer.renderToStaticMarkup (para HTML puramente estático) para generar HTML. Este HTML puede enviarse al cliente, donde ReactDOM.hydrate adjuntará los manejadores de eventos y la lógica interactiva. Aquí un ejemplo básico con Express:
const express = require("express");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const { Button: Boton } = require("@arco-design/web-react");
const { transform } = require("sucrase"); // Suponiendo que Sucrase se usa en el servidor
const codigoComponenteUsuario = `<boton onclick="{()" type="primary"> alert("¡Desde SSR!")}>Botón SSR</boton>`;
const OPCIONES_COMPILACION = { transforms: ["jsx"], production: true };
// Componente React que se renderizará en el servidor
const ComponentePrincipalSSR = () => {
const getComponenteDinamico = () => {
// Compilar el código del usuario en el servidor
const { code: codigoCompilado } = transform(`return (${codigoComponenteUsuario.trim()});`, OPCIONES_COMPILACION);
const contextoServidor = { React, Boton };
const codigoEjecutable = `with(contextoServidor) { ${codigoCompilado} }`;
const ComponenteResultante = new Function("contextoServidor", codigoEjecutable)(contextoServidor);
return ComponenteResultante;
};
return React.createElement("div", null, getComponenteDinamico());
};
const app = express();
app.use('/', function(req, res) {
// Renderizar el componente a una cadena HTML en el servidor
const htmlContent = ReactDOMServer.renderToString(React.createElement(ComponentePrincipalSSR));
res.send(
`
<html>
<head>
<title>Previsualización SSR</title>
<link rel="stylesheet" href="https://unpkg.com/@arco-design/web-react@2.53.0/dist/css/arco.min.css">
<script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
</head>
<body>
<div id="root">${htmlContent}</div>
<script src="https://unpkg.com/@arco-design/web-react@2.53.0/dist/arco.min.js"></script>
<script>
// Código cliente para hidratar
const ComponenteCliente = () => {
const getComponenteDinamicoCliente = () => {
// El cliente también necesita el código compilado para la hidratación.
// En un caso real, esto vendría del servidor o se precompilaría.
const codigoCompiladoCliente = 'return ' + 'React.createElement(arco.Button, { type: "primary", onClick: () => alert("¡Desde SSR!")}, "Botón SSR")';
const contextoCliente = { React, Boton: arco.Button };
const codigoEjecutableCliente = "with(contextoCliente) { " + codigoCompiladoCliente + " }";
const ComponenteResultanteCliente = new Function("contextoCliente", codigoEjecutableCliente)(contextoCliente);
return ComponenteResultanteCliente;
};
return React.createElement("div", null, getComponenteDinamicoCliente());
};
ReactDOM.hydrate(React.createElement(ComponenteCliente), document.getElementById("root"));
</script>
</body>
</html>`
);
});
app.listen(8080, () => {
console.log("Servidor escuchando en el puerto 8080");
});
Consideraciones de Seguridad
La ejecución de código proporcionado por el usuario implica riesgos de seguridad significativos. El más evidente es un ataque de XSS (Cross-Site Scripting) persistente: un usuario malintencionado podría inyectar código que robe las cookies de otros usuarios, enviándolas a un servidor externo. Si el sitio no utiliza HttpOnly para las cookies y el código se almacena y se muestra a otros, se convierte en un riesgo importante. Además, si el código malicioso se ejecuta en el servidor (en un entorno SSR), las consecuencias podrían ser aún más graves.
La premisa fundamental es: "nunca confíes en la entrada del usuario". La seguridad completa es inalcanzable cuando se permite la ejecución de código arbitrario. Nuestro objetivo es minimizar los riesgos. Las mejores prácticas incluyen limitar el alcance de la entrada del usuario (por ejemplo, solo para uso interno de la empresa, no expuesto a usuarios externos) y, fundamentalmente, restringir el acceso a objetos globales como window.
Control de Dependencias
Para limitar el acceso a objetos globales, podemos aprovechar la forma en que new Function() maneja su ámbito y las propiedades del objeto with. Podemos construir un objeto contexto que contenga solo las variables que deseamos exponer. Luego, para el resto de las propiedades globales de window, podemos agregarlas al contexto con un valor de null.
Cuando la función dinámica intente acceder a una variable como document o XMLHttpRequest, primero buscará en nuestro objeto contexto. Si encuentra document: null, usará ese null en lugar de continuar la búsqueda en el ámbito global de window. Esto es aplicable tanto si pasamos los valores como parámetros directos o si usamos la sentencia with.
const objetoGlobal = typeof window !== 'undefined' ? window : global; // Adaptar para entornos Node/navegador
const contextoBloqueado = Object.keys(Object.getOwnPropertyDescriptors(objetoGlobal))
.filter(key => typeof key === 'string' && !key.includes("-")) // Filtrar claves no válidas para JS
.reduce((acc, key) => ({ ...acc, [key]: null }), {}); // Todas las propiedades globales a null
contextoBloqueado.console = console; // Permitir el acceso a la consola
const codigoAtaque = "console.log(window, document, XMLHttpRequest, eval, Function, fetch);";
// Con 'new Function' usando parámetros
const funcBloqueadaParams = new Function(...Object.keys(contextoBloqueado), codigoAtaque.trim());
funcBloqueadaParams(...Object.values(contextoBloqueado)); // Salida: null null null null null null
// Con 'with'
const codigoWithBloqueado = `with(contexto) { ${codigoAtaque.trim()} }`;
const funcBloqueadaWith = new Function("contexto", codigoWithBloqueado);
funcBloqueadaWith(contextoBloqueado); // Salida: null null null null null null
Proxy para Sandboxing
El objeto Proxy es una herramienta poderosa para el sandboxing, ya que permite interceptar y redefinir operaciones básicas en un objeto. Combinado con with, podemos tener un control muy preciso sobre las propiedades accesibles. Al interceptar las propiedades de nuestro objeto contexto, podemos implementar una lista blanca para determinar qué se puede acceder.
Una característica crucial es que la sentencia with usa el operador in para verificar si una propiedad existe en el objeto. Si has del Proxy siempre devuelve true, se evita que la búsqueda continúe por la cadena de ámbito global. Además, funciones como alert o setTimeout deben ejecutarse en el contexto de window. Podemos identificar estas funciones (son no-constructoras y no tienen prototype) y enlazarles window al devolverlas.
interface SandboxDependencies {
[key: string | symbol]: any;
React: typeof React;
// ... otras dependencias permitidas
}
// Lista de elementos globales que se deben enlazar explícitamente a 'window'
const GLOBALES_ENLAZABLES = ['alert', 'setTimeout', 'setInterval', 'fetch', 'console'];
/**
* Crea un sandbox seguro utilizando Proxy para limitar el acceso a propiedades.
* @param initialDeps Dependencias iniciales (lista blanca) que el sandbox puede acceder.
* @returns Un objeto Proxy que actúa como sandbox.
*/
export const crearSandboxSeguro = (initialDeps: SandboxDependencies) => {
const topWindow = typeof window === "undefined" ? global : window;
const dependenciasPermitidas: (keyof SandboxDependencies | string)[] = [
...Object.keys(initialDeps),
...GLOBALES_ENLAZABLES,
];
const proxySandbox = new Proxy(initialDeps, {
// Intercepta las comprobaciones de 'in' (usadas por 'with')
has: (target, prop) => {
// Siempre reportar que la propiedad existe para evitar la búsqueda en el ámbito global.
return true;
},
// Intercepta el acceso a propiedades
get: (target, prop, receiver) => {
if (dependenciasPermitidas.includes(prop as string)) {
const valor = (target as any)[prop];
// Si es una función global que requiere el contexto de 'window', la enlazamos.
if (typeof valor === 'function' && !valor.prototype && GLOBALES_ENLAZABLES.includes(prop as string)) {
return valor.bind(topWindow);
}
return valor;
} else {
// Bloquear el acceso a propiedades no permitidas
return undefined; // O null, dependiendo de la política
}
},
// Intercepta la asignación de propiedades
set: (target, prop, newValue, receiver) => {
if (dependenciasPermitidas.includes(prop as string)) {
(target as any)[prop] = newValue;
}
return true; // Indicar que la asignación fue exitosa (aunque no siempre se realice)
},
});
return proxySandbox;
};
Adicionalmente, para prevenir la fuga de contexto (por ejemplo, que el código de usuario acceda a this para escapar del sandbox), es crucial que la función dinámica se llame con un this explícitamente establecido a null. Esto se puede lograr modificando la inyección del puente:
export const crearComponenteEnSandbox = (compiledCode: string, context: SandboxDependencies) => {
const idUnico = uuidv4();
context._REACT_BRIDGE_ = {};
const puente = context._REACT_BRIDGE_ as Record<string unknown="">;
const codigoFinal = `
with(contexto) {
// La función interna se llama con .call(null) para asegurar que 'this' sea null
function obtenerComponente(){ "use strict"; return (${compiledCode.trim()}); };
_REACT_BRIDGE_["${idUnico}"] = obtenerComponente.call(null);
}
`;
const funcEjecutora = new Function("contexto", codigoFinal);
funcEjecutora.call(null, context); // También se llama la función contenedora con .call(null)
return puente[idUnico];
};
</string>
Iframe para Aislamiento Total
Aunque los Proxies son potentes, la forma más robusta de aislamiento es ejecutar el código de usuario dentro de un iframe. Un iframe crea un contexto de navegación completamente separado, con su propio window, document y cadena de ámbito. Esto es lo que utilizan herramientas como CodeSandbox.
Podemos crear un iframe, obtener su objeto contentWindow (que es el objeto window de ese iframe) y usarlo para ejecutar el código del usuario. Además, el atributo sandbox del iframe permite imponer restricciones de seguridad adicionales, como allow-same-origin para permitir scripts del mismo origen y allow-scripts para ejecutar JavaScript, pero restringiendo otras acciones como envíos de formularios, pop-ups o navegación de la ventana principal.
const iframeSandbox = document.createElement("iframe");
iframeSandbox.src = "about:blank"; // Carga una página vacía
iframeSandbox.style.position = "fixed";
iframeSandbox.style.left = "-10000px"; // Oculta el iframe
iframeSandbox.style.top = "-10000px";
iframeSandbox.setAttribute("sandbox", "allow-same-origin allow-scripts"); // Permite scripts y mismo origen
document.body.appendChild(iframeSandbox);
const winIframe = iframeSandbox.contentWindow; // Obtiene el objeto window del iframe
document.body.removeChild(iframeSandbox); // Se puede eliminar del DOM una vez se obtiene el window
console.log(winIframe && winIframe !== window && winIframe.parent !== window); // true (es un objeto window aislado)
Luego, podemos usar este window aislado del iframe para construir nuestra función dinámica. Podemos combinarlo con un Proxy para que el código del usuario acceda primero a nuestras dependencias inyectadas, y luego al window del iframe para cualqueir función global necesaria.
/**
* Crea un sandbox con iframe para un aislamiento robusto.
* @param iframeWin El objeto window del iframe.
* @param protoSandbox Las dependencias base para el sandbox.
* @returns Un objeto Proxy que combina las dependencias y el window del iframe.
*/
export const crearSandboxIframe = (iframeWin: Window, protoSandbox: SandboxDependencies) => {
const sandboxBase = Object.create(protoSandbox); // Heredar dependencias
return new Proxy(sandboxBase, {
get(_, key) {
// Priorizar las dependencias inyectadas, luego el window del iframe.
return sandboxBase[key] || (iframeWin as any)[key];
},
has: () => true, // Siempre true para evitar búsquedas en el window principal
set(_, key, newValue) {
sandboxBase[key] = newValue; // Las asignaciones modifican el sandbox local
return true;
},
});
};
/**
* Renderiza un componente en un sandbox de iframe.
* @param compiledCode Código React compilado.
* @param baseDependencies Dependencias base para el sandbox.
* @returns La instancia del componente React extraída.
*/
export const renderizarEnIframeSandbox = (compiledCode: string, baseDependencies: SandboxDependencies) => {
const id = uuidv4();
baseDependencies._REACT_BRIDGE_ = {};
const puente = baseDependencies._REACT_BRIDGE_ as Record<string unknown="">;
const iframe = document.createElement("iframe");
iframe.src = "about:blank";
iframe.style.position = "fixed";
iframe.style.left = "-10000px";
iframe.style.top = "-10000px";
iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
document.body.appendChild(iframe);
const iframeWindow = iframe.contentWindow;
document.body.removeChild(iframe); // Eliminar el iframe del DOM si solo se necesita su window
// Verificar que iframeWindow no sea null y crear el sandbox
if (!iframeWindow) throw new Error("No se pudo obtener el window del iframe.");
const contextoFinalSandbox = crearSandboxIframe(iframeWindow, baseDependencies);
// Crear la función usando el constructor Function del iframeWindow
// Esto asegura que la función se ejecute en el ámbito del iframe
const funcEnIframe = new iframeWindow.Function(
"contexto",
`with(contexto) {
function obtenerComp(){ "use strict"; return (${compiledCode.trim()}); };
_REACT_BRIDGE_["${id}"] = obtenerComp.call(null);
}`
);
funcEnIframe.call(null, contextoFinalSandbox); // Ejecutar la función
return puente[id]; // Recuperar el componente del puente
};
</string>