ylioo

HTTP Proxy with Request Interception

const fs = require("fs");
const path = require("path");
const pkg = require("./package.json");

const port = process.env.PORT || 3000;
let web_o = require("http-proxy/lib/http-proxy/passes/web-outgoing");

const appName = pkg.name;
const removeUrlDuplicateSlash = (url) => url.replace(/([^:]\/)\/+/g, "$1");

web_o = Object.keys(web_o).map(function (pass) {
  return web_o[pass];
});

module.exports = (config, env) => {
  if (env === "development" && config.devServer) {
    const apis = {
      "/custom-extensions": `http://127.0.0.1:${port}/`,
      [`/${appName}`]: `http://127.0.0.1:${port}/`,
    };

    Object.keys(apis).forEach((path) => {
      config.devServer.proxy.push({
        context: [path],
        secure: false,
        changeOrigin: true,
        target: apis[path],
        pathRewrite: (from, req) => {
          let target = from.replace(/\/service/, "");

          if (path === "/custom-extensions") {
            target = from.replace("/custom-extensions", "custom-static");
          }

          const origin = removeUrlDuplicateSlash(
            `${req.protocol}://${req.headers.host}${from}`
          );
          const upstream = removeUrlDuplicateSlash(`${apis[path]}${target}`);

          console.log(`${req.method} [proxy]`);
          console.log(`origin  : ${origin}`);
          console.log(`upstream: ${upstream}`);

          return target;
        },
        selfHandleResponse: true,
        onProxyRes: async (proxyRes, req, res) => {
          const reqUrl = req.url;
          if (
            proxyRes.headers["content-type"] === "text/html" &&
            reqUrl.includes("/main")
          ) {
            return responseInterceptor(
              async (responseBuffer, proxyRes, req, res) => {
                const response = responseBuffer.toString("utf8");
                const isMobile = reqUrl.includes("main-mobile");
                const html = await readHTML(isMobile);
                return response.replace(
                  "<!-- custom_head_start_inject -->",
                  `<!-- custom_head_start_inject -->${html}`
                );
              }
            )(proxyRes, req, res);
          }

          if (!res.headersSent) {
            for (var i = 0; i < web_o.length; i++) {
              if (web_o[i](req, res, proxyRes, {})) {
                break;
              }
            }
          }
          proxyRes.pipe(res);
        },
      });
    });
    config.output.publicPath = `/${appName}/`;
    config.devtool = "source-map";
    config.devServer.client = { overlay: false };
    config.devServer.static = {
      publicPath: `/custom-static`,
      directory: path.resolve(__dirname, "../dist/custom-extensions"),
      watch: true,
    };
  }

  return config;
};

const readHTML = async (isMobile) => {
  const html = fs.readFileSync(
    path.resolve(
      __dirname,
      `../dist/custom-extensions/${
        isMobile ? "main-mobile" : "main"
      }/custom_head_start_inject.html`
    ),
    "utf8"
  );
  return html;
};

import type * as http from 'http';
import * as zlib from 'zlib';

type Interceptor = (
  buffer: Buffer,
  proxyRes: http.IncomingMessage,
  req: http.IncomingMessage,
  res: http.ServerResponse
) => Promise<Buffer | string>;

/**
 * Intercept responses from upstream.
 * Automatically decompress (deflate, gzip, brotli).
 * Give developer the opportunity to modify intercepted Buffer and http.ServerResponse
 *
 * NOTE: must set options.selfHandleResponse=true (prevent automatic call of res.end())
 */
function responseInterceptor(interceptor: Interceptor) {
  return async function proxyRes(
    proxyRes: http.IncomingMessage,
    req: http.IncomingMessage,
    res: http.ServerResponse
  ): Promise<void> {
    const originalProxyRes = proxyRes;
    let buffer = Buffer.from('', 'utf8');

    // decompress proxy response
    const _proxyRes = decompress(proxyRes, proxyRes.headers['content-encoding']);

    // concat data stream
    _proxyRes.on('data', (chunk) => (buffer = Buffer.concat([buffer, chunk])));

    _proxyRes.on('end', async () => {
      // copy original headers
      copyHeaders(proxyRes, res);

      // call interceptor with intercepted response (buffer)
      const interceptedBuffer = Buffer.from(await interceptor(buffer, originalProxyRes, req, res));

      // set correct content-length (with double byte character support)
      res.setHeader('content-length', Buffer.byteLength(interceptedBuffer, 'utf8'));

      res.write(interceptedBuffer);
      res.end();
    });

    _proxyRes.on('error', (error) => {
      res.end(`Error fetching proxied request: ${error.message}`);
    });
  };
}

/**
 * Streaming decompression of proxy response
 * source: https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L116
 */
function decompress(proxyRes: http.IncomingMessage, contentEncoding: string) {
  let _proxyRes = proxyRes;
  let decompress;

  switch (contentEncoding) {
    case 'gzip':
      decompress = zlib.createGunzip();
      break;
    case 'br':
      decompress = zlib.createBrotliDecompress();
      break;
    case 'deflate':
      decompress = zlib.createInflate();
      break;
    default:
      break;
  }

  if (decompress) {
    _proxyRes.pipe(decompress);
    _proxyRes = decompress;
  }

  return _proxyRes;
}

/**
 * Copy original headers
 * https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L78
 */
function copyHeaders(originalResponse, response) {
  response.statusCode = originalResponse.statusCode;
  response.statusMessage = originalResponse.statusMessage;

  if (response.setHeader) {
    let keys = Object.keys(originalResponse.headers);

    // ignore chunked, brotli, gzip, deflate headers
    keys = keys.filter((key) => !['content-encoding', 'transfer-encoding'].includes(key));

    keys.forEach((key) => {
      let value = originalResponse.headers[key];

      if (key === 'set-cookie') {
        // remove cookie domain
        value = Array.isArray(value) ? value : [value];
        value = value.map((x) => x.replace(/Domain=[^;]+?/i, ''));
      }

      response.setHeader(key, value);
    });
  } else {
    response.headers = originalResponse.headers;
  }
}