/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ const zlib = require('zlib'); const { Writable } = require('stream'); const { pipeline } = require('stream/promises'); const { ZSTDDecompress } = require('simple-zstd'); const yargs = require('yargs'); const { hideBin } = require('yargs/helpers'); const parsedArgs = yargs(hideBin(process.argv)).parse(); const parsedEnvArg = () => { let envArgs = {}; if (parsedArgs.env) { envArgs = yargs(parsedArgs.env).argv; } return { ...process.env, ...envArgs }; }; const { supersetPort = 8088, superset: supersetUrl = null } = parsedEnvArg(); const backend = (supersetUrl || `http://localhost:${supersetPort}`).replace( '//+$/', '', ); // strip ending backslash let manifest; function isHTML(res) { const CONTENT_TYPE_HEADER = 'content-type'; const contentType = res.getHeader ? res.getHeader(CONTENT_TYPE_HEADER) : res.headers[CONTENT_TYPE_HEADER]; return contentType.includes('text/html'); } function toDevHTML(originalHtml) { let html = originalHtml.replace( /(\s*)([\s\S]*)(<\/title>)/i, '$1[DEV] $2 $3', ); if (manifest) { const loaded = new Set(); // replace bundled asset files, HTML comment tags generated by Jinja macros // in superset/templates/superset/partials/asset_bundle.html html = html.replace( /<!-- Bundle (css|js) (.*?) START -->[\s\S]*?<!-- Bundle \1 \2 END -->/gi, (match, assetType, bundleName) => { if (bundleName in manifest.entrypoints) { return `<!-- DEV bundle: ${bundleName} ${assetType} START -->\n ${( manifest.entrypoints[bundleName][assetType] || [] ) .filter(chunkFilePath => { if (loaded.has(chunkFilePath)) { return false; } loaded.add(chunkFilePath); return true; }) .map(chunkFilePath => assetType === 'css' ? `<link rel="stylesheet" type="text/css" href="${chunkFilePath}" />` : `<script src="${chunkFilePath}"></script>`, ) .join( '\n ', )}\n <!-- DEV bundle: ${bundleName} ${assetType} END -->`; } return match; }, ); } return html; } function copyHeaders(originalResponse, response) { response.statusCode = originalResponse.statusCode; response.statusMessage = originalResponse.statusMessage; if (response.setHeader) { let keys = Object.keys(originalResponse.headers); if (isHTML(originalResponse)) { keys = keys.filter( key => key !== 'content-encoding' && key !== 'content-length', ); } 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, '')); } else if (key === 'location') { // set redirects to use local URL value = (value || '').replace(backend, ''); } response.setHeader(key, value); }); } else { response.headers = originalResponse.headers; } } /** * Manipulate HTML server response to replace asset files with * local webpack-dev-server build. */ async function processHTML(proxyResponse, response) { const responseEncoding = proxyResponse.headers['content-encoding']; let uncompress; // `simple-zstd`'s decompress stream is backed by a real `zstd -d` child // process, but the stream it hands back doesn't forward `.destroy()` to // that child -- so when `pipeline` below destroys it on an upstream // error, the child is left running with its stdin still open instead of // exiting. Track the underlying process (via the `started` event // `simple-zstd` emits once it's actually spawned) so it can be killed // explicitly; `killZstdChild` covers the race where destruction happens // before that event fires. let zstdChild; let killZstdChild = false; // decode GZIP response if (responseEncoding === 'gzip') { uncompress = zlib.createGunzip(); } else if (responseEncoding === 'br') { uncompress = zlib.createBrotliDecompress(); } else if (responseEncoding === 'deflate') { uncompress = zlib.createInflate(); } else if (responseEncoding === 'zstd') { uncompress = ZSTDDecompress(); uncompress.once('started', childProcess => { if (killZstdChild) { childProcess.kill(); } else { zstdChild = childProcess; } }); } const chunks = []; const collector = new Writable({ write(chunk, encoding, callback) { chunks.push(chunk); callback(); }, }); try { // `pipeline` (unlike `.pipe()`) destroys every stream in the chain -- // and rejects -- as soon as any one of them errors or closes // prematurely. A proxied backend connection dying mid-response (e.g. // the Flask dev server's reloader restarting on a file save) is // exactly that case: plain `.pipe()` never forwards the upstream // error/close to `uncompress`, so `uncompress` (and, for `zstd`, the // child process backing it) sits waiting for input that will never // arrive, `end`/`error` never fire, and the client-facing response // hangs forever instead of failing fast. await pipeline( ...(uncompress ? [proxyResponse, uncompress, collector] : [proxyResponse, collector]), ); } finally { if (zstdChild) { zstdChild.kill(); } else { killZstdChild = true; } } response.end(toDevHTML(Buffer.concat(chunks).toString())); } module.exports = newManifest => { manifest = newManifest; return { context: path => { // Don't proxy hot update files - webpack-dev-server needs to serve these directly for HMR if (path.includes('.hot-update.')) { return false; } // Don't proxy WebSocket connections for HMR if (path === '/ws') { return false; } return true; }, target: backend, hostRewrite: true, changeOrigin: true, cookieDomainRewrite: '', // remove cookie domain selfHandleResponse: true, // so that the onProxyRes takes care of sending the response onProxyRes(proxyResponse, request, response) { try { copyHeaders(proxyResponse, response); if (isHTML(response)) { // For HTML responses, flush headers before processing starts. // processHTML awaits pipeline() and calls response.end() once it // resolves; the .catch() below handles a stream failure that // surfaces after those headers are already sent. response.flushHeaders(); processHTML(proxyResponse, response).catch(e => { // eslint-disable-next-line no-console console.error(`Error requesting ${request.path} from proxy:`, e); if (!response.writableEnded) { response.end( `Error requesting ${request.path} from proxy: ${e.message}`, ); } }); } else { const isCSV = (proxyResponse.headers['content-type'] || '').includes( 'text/csv', ); if (isCSV) { response.flushHeaders(); proxyResponse.on('data', chunk => { response.write(chunk); if (response.flush) { response.flush(); } }); proxyResponse.on('end', () => { response.end(); }); proxyResponse.on('error', () => { response.end(); }); } else { response.flushHeaders(); // Same pipe()-doesn't-propagate-errors gotcha fixed in // processHTML above: a plain .pipe() here leaves `response` // hanging forever if the backend connection drops mid-body // (e.g. the Flask dev server's reloader restarting on a file // save) while streaming non-HTML, non-CSV payloads such as // chart/dashboard JSON. `pipeline()` ends/destroys `response` // as soon as `proxyResponse` errors or closes prematurely. pipeline(proxyResponse, response).catch(e => { // eslint-disable-next-line no-console console.error(`Error requesting ${request.path} from proxy:`, e); if (!response.writableEnded) { response.end(); } }); } } } catch (e) { // Only try to set headers if they haven't been sent yet if (!response.headersSent) { response.setHeader('content-type', 'text/plain'); } response.write(`Error requesting ${request.path} from proxy:\n\n`); response.end(e.stack); } }, }; };