express-intercept
Version:
Build Express middleware to intercept / replace / inspect / transform response
421 lines (415 loc) • 13.5 kB
JavaScript
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 };