response-interceptor.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import * as zlib from 'node:zlib';
  2. import { Debug } from '../debug.js';
  3. import { getFunctionName } from '../utils/function.js';
  4. const debug = Debug.extend('response-interceptor');
  5. /**
  6. * Intercept responses from upstream.
  7. * Automatically decompress (deflate, gzip, brotli, zstd).
  8. * Give developer the opportunity to modify intercepted Buffer and http.ServerResponse
  9. *
  10. * NOTE: must set options.selfHandleResponse=true (prevent automatic call of res.end())
  11. *
  12. * @example
  13. *
  14. * ```ts
  15. * createProxyMiddleware({
  16. * target: 'http://example.com',
  17. * selfHandleResponse: true, // MUST set selfHandleResponse=true
  18. * on: {
  19. * proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
  20. * // modify intercepted buffer and return modified buffer
  21. * const modifiedBuffer = Buffer.from(buffer.toString().replace(/Example/g, 'Demo'), 'utf8');
  22. * return modifiedBuffer;
  23. * }),
  24. * }
  25. * });
  26. * ```
  27. */
  28. export function responseInterceptor(interceptor) {
  29. return async function proxyResResponseInterceptor(proxyRes, req, res) {
  30. debug('intercept proxy response');
  31. const originalProxyRes = proxyRes;
  32. const chunks = [];
  33. let bufferLength = 0;
  34. // Bodyless responses (HEAD, 1xx, 204, 304) must not be decompressed.
  35. const contentEncoding = isBodylessResponse(proxyRes.statusCode, req.method)
  36. ? undefined
  37. : proxyRes.headers['content-encoding'];
  38. // decompress proxy response
  39. const _proxyRes = decompress(proxyRes, contentEncoding);
  40. // collect data chunks and concatenate once on end to avoid repeated full-buffer copies
  41. _proxyRes.on('data', (chunk) => {
  42. const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
  43. chunks.push(chunkBuffer);
  44. bufferLength += chunkBuffer.length; // precalculate Buffer length for slightly better performance on Buffer.concat()
  45. });
  46. _proxyRes.on('end', async () => {
  47. const buffer = Buffer.concat(chunks, bufferLength);
  48. chunks.length = 0; // clear chunks array
  49. bufferLength = 0;
  50. // copy original headers
  51. copyHeaders(proxyRes, res);
  52. // RFC 9110: HEAD and 1xx/204/304 responses do not include content.
  53. // End the response after headers to avoid writing an invalid body.
  54. if (isBodylessResponse(proxyRes.statusCode, req.method)) {
  55. res.end();
  56. return;
  57. }
  58. // call interceptor with intercepted response (buffer)
  59. debug('call interceptor function: %s', getFunctionName(interceptor));
  60. const interceptedBuffer = Buffer.from(await interceptor(buffer, originalProxyRes, req, res));
  61. // set correct content-length (with double byte character support)
  62. debug('set content-length: %s', Buffer.byteLength(interceptedBuffer));
  63. // Buffered responses cannot preserve trailer framing.
  64. // Remove trailer declaration (and transfer-encoding just in case) before setting content-length.
  65. res.removeHeader('trailer');
  66. res.removeHeader('transfer-encoding');
  67. res.setHeader('content-length', Buffer.byteLength(interceptedBuffer));
  68. debug('write intercepted response');
  69. res.write(interceptedBuffer);
  70. res.end();
  71. });
  72. _proxyRes.on('error', (error) => {
  73. chunks.length = 0; // clear chunks array
  74. bufferLength = 0;
  75. res.end(`Error fetching proxied request: ${error.message}`);
  76. });
  77. };
  78. }
  79. function isBodylessResponse(statusCode, method) {
  80. return (method?.toUpperCase() === 'HEAD' ||
  81. (statusCode !== undefined &&
  82. ((statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304)));
  83. }
  84. /**
  85. * Streaming decompression of proxy response
  86. * source: https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L116
  87. */
  88. function decompress(proxyRes, contentEncoding) {
  89. let _proxyRes = proxyRes;
  90. let decompress;
  91. switch (contentEncoding) {
  92. case 'gzip':
  93. decompress = zlib.createGunzip();
  94. break;
  95. case 'br':
  96. decompress = zlib.createBrotliDecompress();
  97. break;
  98. case 'deflate':
  99. decompress = zlib.createInflate();
  100. break;
  101. case 'zstd':
  102. decompress = zlib.createZstdDecompress();
  103. break;
  104. default:
  105. break;
  106. }
  107. if (decompress) {
  108. debug(`decompress proxy response with 'content-encoding': %s`, contentEncoding);
  109. _proxyRes.pipe(decompress);
  110. _proxyRes = decompress;
  111. }
  112. return _proxyRes;
  113. }
  114. /**
  115. * Copy original headers
  116. * https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L78
  117. */
  118. function copyHeaders(originalResponse, response) {
  119. debug('copy original response headers');
  120. if (originalResponse.statusCode) {
  121. response.statusCode = originalResponse.statusCode;
  122. }
  123. if (originalResponse.statusMessage) {
  124. response.statusMessage = originalResponse.statusMessage;
  125. }
  126. if (response.setHeader) {
  127. let keys = Object.keys(originalResponse.headers);
  128. // ignore encoding/framing headers that are incompatible with buffered interception
  129. keys = keys.filter((key) => !['content-encoding', 'transfer-encoding', 'trailer'].includes(key));
  130. keys.forEach((key) => {
  131. let value = originalResponse.headers[key];
  132. if (key === 'set-cookie' && value) {
  133. // remove cookie domain
  134. value = Array.isArray(value) ? value : [value];
  135. value = value.map((x) => x.replace(/Domain=[^;]+?/i, ''));
  136. }
  137. response.setHeader(key, value);
  138. });
  139. }
  140. else {
  141. if ('headers' in response) {
  142. response.headers = originalResponse.headers;
  143. }
  144. }
  145. }