UNPKG

express-intercept

Version:

Build Express middleware to intercept / replace / inspect / transform response

421 lines (415 loc) 13.5 kB
import { Readable } from 'stream'; import * as zlib from 'zlib'; import { ASYNC, IF, CATCH } from 'async-request-handler'; // _compression.ts const decoders = { br: zlib.brotliDecompressSync, gzip: zlib.gunzipSync, deflate: zlib.inflateSync, }; const encoders = { br: zlib.brotliCompressSync, gzip: zlib.gzipSync, deflate: zlib.deflateSync, }; function findEncoding(encoding) { return String(encoding).split(/\W+/).filter(e => !!decoders[e]).shift(); } function decompressBuffer(buf, encoding) { const decoder = decoders[encoding]; return decoder(buf); } function compressBuffer(buf, encoding) { const encoder = encoders[encoding]; return encoder(buf); } // _payload.ts function send(queue, dest, cb) { let error; if (queue.length === 1) { const item = queue[0]; try { dest.end(item[0], item[1], sendResult); } catch (e) { catchError(e); } } else { try { queue.forEach(item => { if (!error) dest.write(item[0], item[1], catchError); }); } catch (e) { catchError(e); } // close stream even after error try { dest.end(sendResult); } catch (e) { catchError(e); } } if (cb) cb(); // success callback function catchError(e) { error = error || e; } function sendResult(e) { if (cb) cb(e || error); cb = null; // callback only once } } class ResponsePayload { res; queue = []; constructor(res) { this.res = res; // } push(chunk, encoding) { if (chunk == null) return; // EOF this.queue.push([chunk, encoding]); } pipe(destination) { send(this.queue, destination); return destination; } getBuffer() { const { queue, res } = this; // force Buffer const buffers = queue.map(item => Buffer.isBuffer(item[0]) ? item[0] : Buffer.from(item[0], item[1])); // concat Buffer let buffer = (buffers.length === 1) ? buffers[0] : Buffer.concat(buffers); // decompress Buffer const encoding = findEncoding(res.getHeader("Content-Encoding")); if (encoding && buffer.length) { buffer = decompressBuffer(buffer, encoding); } return buffer; } setBuffer(buffer) { const { queue, res } = this; if (!buffer) buffer = Buffer.of(); // ETag: var etagFn = res.app && res.app.get('etag fn'); if ("function" === typeof etagFn) { res.setHeader("ETag", etagFn(buffer)); } else { res.removeHeader("ETag"); } // recompress Buffer as compressed before const encoding = findEncoding(res.getHeader("Content-Encoding")); if (encoding && buffer.length) { buffer = compressBuffer(buffer, encoding); } const length = +buffer.length; if (length) { res.setHeader("Content-Length", "" + length); } else { res.removeHeader("Content-Length"); } // empty queue.splice(0); // update queue.push([buffer]); } getString() { const { queue } = this; // shortcut when only string chunks given and no Buffer chunks mixed const stringOnly = !queue.filter(chunk => "string" !== typeof chunk[0]).length; if (stringOnly) return queue.map(item => item[0]).join(""); const buffer = this.getBuffer(); // Buffer to string return buffer.toString(); } setString(text) { if (!text) text = ""; const buffer = Buffer.from(text); this.setBuffer(buffer); } } // _handler.ts function buildResponseHandler(options, interceptor, container) { const { _error, _if } = options || {}; return (req, res, next) => { let started; let stopped; let payload; let error; let condition; const original_write = res.write; const intercept_write = res.write = function (chunk, encoding, cb) { if (!started) start(); if (stopped) return original_write.apply(this, arguments); const item = [].slice.call(arguments); if ("function" === typeof item[item.length - 1]) cb = item.pop(); if (payload && item[0]) payload.push(item[0], item[1]); if (cb) cb(); // always success return true; }; const original_end = res.end; const intercept_end = res.end = function (chunk, encoding, cb) { if (!stopped && !started) start(); const _stopped = stopped; if (!stopped) stop(); if (_stopped) return original_end.apply(this, arguments); const item = [].slice.call(arguments); if ("function" === typeof item[item.length - 1]) cb = item.pop(); if (payload && item[0]) payload.push(item[0], item[1]); if (payload) payload.push(null); // EOF if (cb) cb(); // always success if (error) sendError(error); if (!error) finish().catch(sendError); return this; }; return next(); function start() { started = true; try { // _if === null -> RUN // _if(res) === false -> SKIP // _if(res) === true -> RUN // _if(res) instanceof Promise -> RUN condition = !_if || _if(res); if (!condition) return stop(); } catch (e) { error = e; return; } payload = container ? container() : new ResponsePayload(res); } function stop() { stopped = true; // restore to the original methods if (res.write === intercept_write) res.write = original_write; if (res.end === intercept_end) res.end = original_end; } async function finish() { const readable = (await condition) && interceptor && (await interceptor(payload, req, res)) || payload; readable.pipe(res); } function sendError(err) { if (_error) _error(err, req, res, (e) => null); } }; } // express-intercept.ts const requestHandler = errorHandler => { return new RequestHandlerBuilder(errorHandler || defaultErrorHandler); }; const responseHandler = errorHandler => { return new ResponseHandlerBuilder(errorHandler || defaultErrorHandler); }; const defaultErrorHandler = (err, req, res, next) => { console.error(err); // use .send("") instead of .end(), since Node.js v13 res.status(500).send(""); }; class RequestHandlerBuilder { constructor(errorHandler) { this._error = errorHandler; } _error; /** * It appends a test condition to perform the RequestHandler. * Call this for multiple times to add multiple tests in AND condition. * Those tests could avoid unnecessary work later. */ for(condition) { this._for = AND(this._for, condition); return this; } _for; /** * It returns a RequestHandler which connects multiple RequestHandlers. * Use this after `requestHandler()` method but not after `responseHandler()`. */ use(handler, ...more) { let { _for, _error } = this; if (more.length) { handler = ASYNC(handler, ASYNC.apply(null, more)); } else { handler = ASYNC(handler); } if (_for) handler = IF(_for, handler); if (_error) handler = ASYNC(handler, CATCH(_error)); return handler; } /** * It returns a RequestHandler to inspect express Request object (aka `req`). * With `requestHandler()`, it works at request phase as normal RequestHandler works. */ getRequest(receiver) { return this.use(async (req, res, next) => { await receiver(req); next(); }); } } class ResponseHandlerBuilder extends RequestHandlerBuilder { use; /** * It appends a test condition to perform the RequestHandler. * Call this for multiple times to add multiple tests in AND condition. * Those tests could avoid unnecessary response interception work including additional buffering. */ if(condition) { this._if = AND(this._if, condition); return this; } _if; /** * It returns a RequestHandler to replace the response content body as a string. * It gives a single string even when the response stream is chunked and/or compressed. */ replaceString(replacer) { return super.use(buildResponseHandler(this, async (payload, req, res) => { const body = payload.getString(); const replaced = await replacer(body, req, res); if (body === replaced) return; // nothing changed payload.setString(replaced); })); } /** * It returns a RequestHandler to replace the response content body as a Buffer. * It gives a single Buffer even when the response stream is chunked and/or compressed. */ replaceBuffer(replacer) { return super.use(buildResponseHandler(this, async (payload, req, res) => { let body = payload.getBuffer(); body = await replacer(body, req, res); payload.setBuffer(body); })); } /** * It returns a RequestHandler to replace the response content body as a stream.Readable. * Interceptor may need to decompress the response stream when compressed. * Interceptor should return yet another stream.Readable to perform transform the stream. * Interceptor would use stream.Transform for most cases as it is a Readable. * Interceptor could return null or the upstream itself as given if transformation not happened. */ interceptStream(interceptor) { return super.use(buildResponseHandler(this, async (payload, req, res) => { return interceptor(payload, req, res); }, () => new ReadablePayload())); } /** * It returns a RequestHandler to retrieve the response content body as a string. * It gives a single string even when the response stream is chunked and/or compressed. */ getString(receiver) { return super.use(buildResponseHandler(this, async (payload, req, res) => { const body = payload.getString(); await receiver(body, req, res); })); } /** * It returns a RequestHandler to retrieve the response content body as a Buffer. * It gives a single Buffer even when the response stream is chunked and/or compressed. */ getBuffer(receiver) { return super.use(buildResponseHandler(this, async (payload, req, res) => { const body = payload.getBuffer(); await receiver(body, req, res); })); } /** * It returns a RequestHandler to inspect express Request object (aka `req`). * With `responseHandler()`, it works at response returning phase after `res.send()` fired. */ getRequest(receiver) { return super.use(buildResponseHandler(this, async (payload, req, res) => { await receiver(req); })); } /** * It returns a RequestHandler to inspect express Response object (aka `res`) on its response returning phase after res.send() fired. */ getResponse(receiver) { return super.use(buildResponseHandler(this, async (payload, req, res) => { await receiver(res); })); } /** * It returns a RequestHandler to compress the response content. */ compressResponse() { return this.replaceBuffer((buf, req, res) => { const encoding = findEncoding(req.header("Accept-Encoding")); if (encoding) { res.setHeader("Content-Encoding", encoding); // signal to compress with the encoding } return buf; }); } /** * It returns a RequestHandler to decompress the response content. */ decompressResponse() { return this.replaceBuffer((buf, req, res) => { res.removeHeader("Content-Encoding"); // signal NOT to compress return buf; }); } } class ReadablePayload extends Readable { _read() { // don't care } } /** * @private */ function AND(A, B) { if (!A) return B; if (!B) return A; return (arg) => { let result = A(arg); // result: false if (!result) return false; // result: true if (!isThenable(result)) return B(arg); // result: Promise<boolean> return result.then(result => (result && B(arg))); }; } const isThenable = (value) => ("function" === typeof (value.then)); export { requestHandler, responseHandler };