Después de una pausa más larga de lo que tenía planeado vuelvo a retomar el blog. Espero volver a tener el hábito de escribir un post a la semana que mantuve los meses anteriores.
En este primer post, voy a explicar la solución práctica que he aplicado a un problema que tuve semanas atrás. Así que lo dejo más como recordatorio para mí mismo que con el afán de enseñar nada a nadie.

Empecemos.

Escenario

Para ponernos en situación imaginad que tenéis un servidor de NodeJS a la escucha en un puerto. Este hace poco más que las funciones de reverse proxy que hace un backend. El esquema sería algo así:

En principio esto debería ser muy fácil de lograr, y de hecho lo es, con un código como el que sigue:

// server.js

import express from 'express';  
import httpProxy from 'http-proxy';

const PORT = 8080;

const app = express();  
const proxy = new httpProxy.createProxyServer({target: 'http://mybackend.com'});

app.all('*', (req, res) => proxy.web(req, res));

app.listen(PORT, () => console.log('==> Server is listening', PORT));  

Y listo, eso es todo lo que tendría que hacer para tener un reverse proxy simple. La verdad es que, como puedes ver, todo el trabajo duro lo hace node-http-proxy. Así que nosotros no hacemos más que enrutar cualquier petición de cualquier método al proxy.

Problema

Vamos dar una vuelta de tuerca al escenario que hemos dibujado arriba: ahora no queremos que nuestro proxy sea transparente. Para una selección muy concreta de métodos y urls, deseamos modificar el cuerpo de la respuesta de nuestro backend. Concretamente, cuando la respuesta sea un documento en HTML, queremos reemplazar </body> por <script src""></script></body>. Me imagino que verás a donde quiero ir. Quiero "inyectar" un fichero JS en la respuesta de mi backend, sin que este sea consciente de ello.

Si te fijas detenidamente en el diagrama anterior, esto quiere decir que la Resp(1) será distinta a la Resp(2), ya que esta incluirá un tag script de más:

Por supuesto ni qué decir tiene que los tiempos de respuesta no se han de ver modificados en lo más mínimo.

Solución

Una vez entendido el problema, toca buscar una solución. Veremos 3 alternativas, ordenadas por su grado de optimización.

Solución 1: descarga del cuerpo

La primera que se me pasó por la cabeza fue la de tratar de obtener el cuerpo de la respuesta del backend para luego hacer un replace sobre él. Funcionó, pero los tiempos de respuesta del proxy se duplicaron, así que tuve que descartarla por no ser suficientemente óptima.

Solución 2: replace sobre el stream de respuesta

Esta solución es un poco más sofisticada, y se trata de usar la librería harmon, que básicamente manipula el stream de la respuesta. Para ello, por debajo, está usando node-trumpet. Aquí la magia está en que puedes pasarle a Trumpet un stream de strings que representan un html y es capaz de ejecutar un find and replace al vuelo. Esto es exactamente lo que necesitaba. Leyendo la documentación de Harmon, hice lo siguiente:

// server-harmon.js

import express from 'express';  
import httpProxy from 'http-proxy';  
import harmon from 'harmon'

const PORT = 8080;

const app = express();  
const proxy = new httpProxy.createProxyServer({target: 'http://mybackend.com'});

const selects = [  
{
  query: 'body',
  func(node){
    let tag = '';
    let stm = node.createStream({ "outer" : true })
    .on('data', (data) => tag += data )
    .on('end', () => {
      stm.end(
          tag.replace('</body>', '<script async src=""></script></body>')
      )
    });
  }
}];

app.get('/', harmon([], selects, true))

app.all('*', (req, res) => proxy.web(req, res));  
app.listen(PORT, () => console.log('==> Server is listening', PORT));  

Ahora cuando alguien hace una petición GET a la home de mi backend, puede inyectar un script a voluntad. Esta solución funcionó a la primera, pero, por desgracia, solo por llamar a harmon(), aun sin hacer nada dentro, era penalizado con 300ms en el tiempo de respuesta. Muy cerca, pero aún no era suficientemente óptimo.

Solución 3: replace stream de respuesta "a mano"

Estaba claro que la solución radicaba en hacer el find and replace directamente en el stream de escritura de la respuesta. Así que traté de aprender qué hacía No9 para trabajar con Trumpet y ver si podía mejorarlo.

Por otro lado, al ver cómo Harmon jugaba con la posición de los middlewares, recordé algo que una vez supe y ya no recordaba: que los objetos Req/Resp son compartidos entre todos los middelwares que tratan la petición. Así, si modifico la respuesta antes de que el proxy entre en acción, su respuesta será la modificada por mí y no la respuesta original.

Es importante entender cómo funcionan los middlewares en ExpressJS, para entender la solución:

Cada vez que un cliente hace una petición a ExpressJS, este creará un ReadStream(Req) y un WriteStream(Resp). Estos objetos van pasando de middleware a middleware, donde cada uno de ellos lee de la request o escribe en la response hasta que alguno de ellos llama a resp.end(). De esta manera, cierra el stream de escritura y es enviado al cliente (me estoy ahorrando algunos detalles para simplificar la explicación de la solución). La idea de No9 es modificar el stream de lectura de la respuesta, decorando la función resp.write() para pasarle ese buffer a trumpet. Una idea genial pero, como dije, penalizada con 300ms. Lo que traté de hacer fue aplicar el mismo principio y saltarme la parte de Trumpet. Así creé un middleware como el que sigue:

// replace-middleware.js

import {Gunzip} from 'zlib';

const CONTENT_TYPE_HTML = /text\/html/;  
const GZIP_ENCODING = /gzip/i;

/**
 * Where the magic happen!
 * */
const replace = (buff) => {  
  const tag = 'body';
  const snippet = `
    <script async src=\"/js/bundle.js\"></script>
    </${tag}>
  `;
  return buff.toString('utf8').replace(`</${tag}>`, `${snippet}`)
};

export default function raw(req, res, next){  
  const gunzip = Gunzip();
  const _write = res.write;
  const _end = res.end;
  const _writeHead = res.writeHead;


  res.writeHead = (code, headers) => {

    res.isGziped = !!(res.getHeader('content-encoding') || '').match(GZIP_ENCODING);
    res.isHtml = !!(res.getHeader('content-type') || '').match(CONTENT_TYPE_HTML);

    res.removeHeader('Content-Length');
    headers && delete headers['content-length'];

    res.removeHeader('Content-Encoding');
    headers && delete headers['content-encoding'];

    _writeHead.call(res, code, headers);
  }

  res.write = (buff) => {
    !res.isHtml ? _write.call(res, buff)
                : res.isGziped ? gunzip.write(buff)
                               : _write.call(res, replace(buff));
  }

  res.end = (buff) => {
    res.isGziped ? gunzip.end(buff) : _end.call(res, buff);
  }

  gunzip.on('data', buff => _write.call(res, replace(buff)));

  gunzip.on('end', buff =>  _end.call(res, buff));

  next();
};

Aunque es una ponchada de código, fíjate que básicamente estamos decorando los métodos originales del objeto Response para meter la lógica del find and replace en el método write del stream de lectura que representa la respuesta. Ahora, cada vez que tratamos de escribir algo en la respuesta al cliente, comprobamos si es susceptible de ser reemplazado y, en caso afirmativo, lo reemplazamos.

Por otra parte, hay un poco de ruido adicional, porque tenemos que controlar los casos en que la respuesta del backend venga "gzippeada". En realidad, se trata del mismo razonamiento: primero "desgzipeamos" y luego reemplazamos si hemos de hacerlo.

Con este middleware creado, cambiamos nuestro servidor a lo que sigue:

// server.js

import express from 'express';  
import httpProxy from 'http-proxy';  
import raw from './replace-middleware'

const PORT = 8080;

const app = express();  
const proxy = new httpProxy.createProxyServer({target: 'http://mybackend.com'});

app.get('/', raw)  
app.all('*', (req, res) => proxy.web(req, res));

app.listen(PORT, () => console.log('==> Server is listening', PORT));  

En esta solución tenemos un proxy transparente para la mayoría de las peticiones y para la home y agregamos un script a la respuesta, sin penalizar en absoluto los tiempos de respuesta.

Fueron un par de días interesantes hasta llegar a la respuesta que se ajustaba más a mis necesidades, y creo que es interesante compartir con vosotros el proceso que seguí para lograrlo.

Me encataría saber que opináis, y si lo habríais hecho de otra forma.

** Update:

Luego de estar un tiempo preguntandome porque súbitamente el servidor se cerraba. Llegue a este Issue. Así que tuve que parchear un poco el código con lo que se comenta en el hilo.

** Update:

Dar las gracias a @nucliweb por los gráficos que se ha currado para ilustrar este post :)

Si te ha gustado este post, difunde la palabra. Tampoco dudes en dejar comentarios u observaciones. ¡Gracias! :)

Suscríbete a mi lista de correo

* Campos obligatorios