mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
989 lines (988 loc) • 47.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MockttpServer = void 0;
const buffer_1 = require("buffer");
const net = require("net");
const tls = require("tls");
const _ = require("lodash");
const events_1 = require("events");
const portfinder = require("portfinder");
const connect = require("connect");
const cors = require("cors");
const now = require("performance-now");
const async_mutex_1 = require("async-mutex");
const util_1 = require("@httptoolkit/util");
const mockttp_1 = require("../mockttp");
const request_rule_1 = require("../rules/requests/request-rule");
const mocked_endpoint_1 = require("./mocked-endpoint");
const http_combo_server_1 = require("./http-combo-server");
const promise_1 = require("../util/promise");
const util_2 = require("../util/util");
const url_1 = require("../util/url");
const ip_utils_1 = require("../util/ip-utils");
const socket_util_1 = require("../util/socket-util");
const socket_extensions_1 = require("../util/socket-extensions");
const socket_metadata_1 = require("../util/socket-metadata");
const request_utils_1 = require("../util/request-utils");
const buffer_utils_1 = require("../util/buffer-utils");
const header_utils_1 = require("../util/header-utils");
const request_step_impls_1 = require("../rules/requests/request-step-impls");
const websocket_rule_1 = require("../rules/websockets/websocket-rule");
const serverPortCheckMutex = new async_mutex_1.Mutex();
/**
* A in-process Mockttp implementation. This starts servers on the local machine in the
* current process, and exposes methods to directly manage them.
*
* This class does not work in browsers, as it expects to be able to start HTTP servers.
*/
class MockttpServer extends mockttp_1.AbstractMockttp {
constructor(options = {}) {
super(options);
this.requestRuleSets = {};
this.webSocketRuleSets = {};
this.setRequestRules = (...ruleData) => {
Object.values(this.requestRuleSets).flat().forEach(r => r.dispose());
const rules = ruleData.map((ruleDatum) => new request_rule_1.RequestRule(ruleDatum));
this.requestRuleSets = _.groupBy(rules, r => r.priority);
return Promise.resolve(rules.map(r => new mocked_endpoint_1.ServerMockedEndpoint(r)));
};
this.addRequestRules = (...ruleData) => {
return Promise.resolve(ruleData.map((ruleDatum) => {
const rule = new request_rule_1.RequestRule(ruleDatum);
this.addToRuleSets(this.requestRuleSets, rule);
return new mocked_endpoint_1.ServerMockedEndpoint(rule);
}));
};
this.setWebSocketRules = (...ruleData) => {
Object.values(this.webSocketRuleSets).flat().forEach(r => r.dispose());
const rules = ruleData.map((ruleDatum) => new websocket_rule_1.WebSocketRule(ruleDatum));
this.webSocketRuleSets = _.groupBy(rules, r => r.priority);
return Promise.resolve(rules.map(r => new mocked_endpoint_1.ServerMockedEndpoint(r)));
};
this.addWebSocketRules = (...ruleData) => {
return Promise.resolve(ruleData.map((ruleDatum) => {
var _a, _b;
const rule = new websocket_rule_1.WebSocketRule(ruleDatum);
((_a = this.webSocketRuleSets)[_b = rule.priority] ?? (_a[_b] = [])).push(rule);
return new mocked_endpoint_1.ServerMockedEndpoint(rule);
}));
};
this.outgoingPassthroughSockets = new Set();
this.initialDebugSetting = this.debug;
this.httpsOptions = options.https;
this.isHttp2Enabled = options.http2 ?? 'fallback';
this.socksOptions = options.socks ?? false;
this.passthroughUnknownProtocols = options.passthrough?.includes('unknown-protocol') ?? false;
this.maxBodySize = options.maxBodySize ?? Infinity;
this.eventEmitter = new events_1.EventEmitter();
this.app = connect();
if (this.corsOptions) {
if (this.debug)
console.log('Enabling CORS');
const corsOptions = this.corsOptions === true
? { methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] }
: this.corsOptions;
this.app.use(cors(corsOptions));
}
this.app.use(this.handleRequest.bind(this));
}
async start(portParam = { startPort: 8000, endPort: 65535 }) {
this.server = await (0, http_combo_server_1.createComboServer)({
debug: this.debug,
https: this.httpsOptions,
http2: this.isHttp2Enabled,
socks: this.socksOptions,
passthroughUnknownProtocols: this.passthroughUnknownProtocols,
requestListener: this.app,
tlsClientErrorListener: this.announceTlsErrorAsync.bind(this),
tlsPassthroughListener: this.passthroughSocket.bind(this, 'tls'),
rawPassthroughListener: this.passthroughSocket.bind(this, 'raw')
});
// We use a mutex here to avoid contention on ports with parallel setup
await serverPortCheckMutex.runExclusive(async () => {
const port = _.isNumber(portParam)
? portParam
: await portfinder.getPortPromise({
port: portParam.startPort,
stopPort: portParam.endPort
});
if (this.debug)
console.log(`Starting mock server on port ${port}`);
this.server.listen(port);
});
// Handle & report client request errors
this.server.on('clientError', this.handleInvalidHttp1Request.bind(this));
this.server.on('sessionError', this.handleInvalidHttp2Request.bind(this));
// Track the socket of HTTP/2 sessions, for error reporting later:
this.server.on('session', (session) => {
session.on('connect', (session, socket) => {
session.initialSocket = socket;
});
});
this.server.on('upgrade', this.handleWebSocket.bind(this));
return new Promise((resolve, reject) => {
this.server.on('listening', resolve);
this.server.on('error', (e) => {
// Although we try to pick a free port, we may have race conditions, if something else
// takes the same port at the same time. If you haven't explicitly picked a port, and
// we do have a collision, simply try again.
if (e.code === 'EADDRINUSE' && !_.isNumber(portParam)) {
if (this.debug)
console.log('Address in use, retrying...');
// Destroy just in case there is something that needs cleanup here. Catch because most
// of the time this will error with 'Server is not running'.
this.server.destroy().catch(() => { });
resolve(this.start());
}
else {
reject(e);
}
});
});
}
async stop() {
if (this.debug)
console.log(`Stopping server at ${this.url}`);
if (this.server)
await this.server.destroy();
this.reset();
}
enableDebug() {
this.debug = true;
}
reset() {
Object.values(this.requestRuleSets).flat().forEach(r => r.dispose());
this.requestRuleSets = [];
Object.values(this.webSocketRuleSets).flat().forEach(r => r.dispose());
this.webSocketRuleSets = [];
this.debug = this.initialDebugSetting;
this.eventEmitter.removeAllListeners();
}
get address() {
if (!this.server)
throw new Error('Cannot get address before server is started');
return this.server.address();
}
get url() {
if (!this.server)
throw new Error('Cannot get url before server is started');
if (this.httpsOptions) {
return "https://localhost:" + this.port;
}
else {
return "http://localhost:" + this.port;
}
}
get port() {
if (!this.server)
throw new Error('Cannot get port before server is started');
return this.address.port;
}
addToRuleSets(ruleSets, rule) {
var _a;
ruleSets[_a = rule.priority] ?? (ruleSets[_a] = []);
ruleSets[rule.priority].push(rule);
}
async getMockedEndpoints() {
return [
...Object.values(this.requestRuleSets).flatMap(rules => rules.map(r => new mocked_endpoint_1.ServerMockedEndpoint(r))),
...Object.values(this.webSocketRuleSets).flatMap(rules => rules.map(r => new mocked_endpoint_1.ServerMockedEndpoint(r)))
];
}
async getPendingEndpoints() {
const withPendingPromises = (await this.getMockedEndpoints())
.map(async (endpoint) => ({
endpoint,
isPending: await endpoint.isPending()
}));
const withPending = await Promise.all(withPendingPromises);
return withPending.filter(wp => wp.isPending).map(wp => wp.endpoint);
}
async getRuleParameterKeys() {
return []; // Local servers never have rule parameters defined
}
on(event, callback) {
this.eventEmitter.on(event, callback);
return Promise.resolve();
}
announceInitialRequestAsync(request) {
if (this.eventEmitter.listenerCount('request-initiated') === 0)
return;
setImmediate(() => {
const initiatedReq = (0, request_utils_1.buildInitiatedRequest)(request);
this.eventEmitter.emit('request-initiated', Object.assign(initiatedReq, {
timingEvents: _.clone(initiatedReq.timingEvents),
tags: _.clone(initiatedReq.tags)
}));
});
}
announceCompletedRequestAsync(request) {
if (this.eventEmitter.listenerCount('request') === 0)
return;
(0, request_utils_1.waitForCompletedRequest)(request)
.then((completedReq) => {
setImmediate(() => {
this.eventEmitter.emit('request', Object.assign(completedReq, {
timingEvents: _.clone(completedReq.timingEvents),
tags: _.clone(completedReq.tags)
}));
});
})
.catch(console.error);
}
announceResponseAsync(response) {
if (this.eventEmitter.listenerCount('response') === 0)
return;
(0, request_utils_1.waitForCompletedResponse)(response)
.then((res) => {
setImmediate(() => {
this.eventEmitter.emit('response', Object.assign(res, {
timingEvents: _.clone(res.timingEvents),
tags: _.clone(res.tags)
}));
});
})
.catch(console.error);
}
announceWebSocketRequestAsync(request) {
if (this.eventEmitter.listenerCount('websocket-request') === 0)
return;
(0, request_utils_1.waitForCompletedRequest)(request)
.then((completedReq) => {
setImmediate(() => {
this.eventEmitter.emit('websocket-request', Object.assign(completedReq, {
timingEvents: _.clone(completedReq.timingEvents),
tags: _.clone(completedReq.tags)
}));
});
})
.catch(console.error);
}
announceWebSocketUpgradeAsync(response) {
if (this.eventEmitter.listenerCount('websocket-accepted') === 0)
return;
setImmediate(() => {
this.eventEmitter.emit('websocket-accepted', {
...response,
timingEvents: _.clone(response.timingEvents),
tags: _.clone(response.tags)
});
});
}
announceWebSocketMessageAsync(request, direction, content, isBinary) {
const eventName = `websocket-message-${direction}`;
if (this.eventEmitter.listenerCount(eventName) === 0)
return;
setImmediate(() => {
this.eventEmitter.emit(eventName, {
streamId: request.id,
direction,
content,
isBinary,
eventTimestamp: now(),
timingEvents: request.timingEvents,
tags: request.tags
});
});
}
announceWebSocketCloseAsync(request, closeCode, closeReason) {
if (this.eventEmitter.listenerCount('websocket-close') === 0)
return;
setImmediate(() => {
this.eventEmitter.emit('websocket-close', {
streamId: request.id,
closeCode,
closeReason,
timingEvents: request.timingEvents,
tags: request.tags
});
});
}
// Hook the request and socket to announce all WebSocket events after the initial request:
trackWebSocketEvents(request, socket) {
const originalWrite = socket._write;
const originalWriteV = socket._writev;
// Hook the socket to capture our upgrade response:
let data = buffer_1.Buffer.from([]);
socket._writev = undefined;
socket._write = function () {
data = buffer_1.Buffer.concat([data, (0, buffer_utils_1.asBuffer)(arguments[0])]);
return originalWrite.apply(this, arguments);
};
let upgradeCompleted = false;
socket.once('close', () => {
if (upgradeCompleted)
return;
if (data.length) {
request.timingEvents.responseSentTimestamp = now();
const httpResponse = (0, request_utils_1.parseRawHttpResponse)(data, request);
this.announceResponseAsync(httpResponse);
}
else {
// Connect closed during upgrade, before we responded:
request.timingEvents.abortedTimestamp = now();
this.announceAbortAsync(request);
}
});
socket.once('ws-upgrade', (ws) => {
upgradeCompleted = true;
// Undo our write hook setup:
socket._write = originalWrite;
socket._writev = originalWriteV;
request.timingEvents.wsAcceptedTimestamp = now();
const httpResponse = (0, request_utils_1.parseRawHttpResponse)(data, request);
this.announceWebSocketUpgradeAsync(httpResponse);
ws.on('message', (data, isBinary) => {
this.announceWebSocketMessageAsync(request, 'received', data, isBinary);
});
// Wrap ws.send() to report all sent data:
const _send = ws.send;
const self = this;
ws.send = function (data, options) {
const isBinary = options.binary
?? typeof data !== 'string';
_send.apply(this, arguments);
self.announceWebSocketMessageAsync(request, 'sent', (0, buffer_utils_1.asBuffer)(data), isBinary);
};
ws.on('close', (closeCode, closeReason) => {
if (closeCode === 1006) {
// Not a clean close!
request.timingEvents.abortedTimestamp = now();
this.announceAbortAsync(request);
}
else {
request.timingEvents.wsClosedTimestamp = now();
this.announceWebSocketCloseAsync(request, closeCode === 1005
? undefined // Clean close, but with a close frame with no status
: closeCode, closeReason.toString('utf8'));
}
});
});
}
async announceAbortAsync(request, abortError) {
setImmediate(() => {
const req = (0, request_utils_1.buildInitiatedRequest)(request);
this.eventEmitter.emit('abort', Object.assign(req, {
timingEvents: _.clone(req.timingEvents),
tags: _.clone(req.tags),
error: abortError ? {
name: abortError.name,
code: abortError.code,
message: abortError.message,
stack: abortError.stack
} : undefined
}));
});
}
async announceTlsErrorAsync(socket, request) {
// Ignore errors after TLS is setup, those are client errors
if (socket instanceof tls.TLSSocket && socket[socket_extensions_1.TlsSetupCompleted])
return;
setImmediate(() => {
if (this.debug)
console.warn(`TLS client error: ${JSON.stringify(request)}`);
this.eventEmitter.emit('tls-client-error', request);
});
}
async announceClientErrorAsync(socket, error) {
// Ignore errors before TLS is setup, those are TLS errors
if (socket instanceof tls.TLSSocket &&
!socket[socket_extensions_1.TlsSetupCompleted] &&
error.errorCode !== 'ERR_HTTP2_ERROR' // Initial HTTP/2 errors are considered post-TLS
)
return;
setImmediate(() => {
if (this.debug)
console.warn(`Client error: ${JSON.stringify(error)}`);
this.eventEmitter.emit('client-error', error);
});
}
async announceRuleEventAsync(requestId, ruleId, eventType, eventData) {
setImmediate(() => {
this.eventEmitter.emit('rule-event', {
requestId,
ruleId,
eventType,
eventData
});
});
}
/**
* For both normal requests & websockets, we do some standard preprocessing to ensure we have the absolute
* URL destination in place, and timing, tags & id metadata all ready for an OngoingRequest.
*/
preprocessRequest(req, type) {
try {
(0, request_utils_1.parseRequestBody)(req, { maxSize: this.maxBodySize });
let rawHeaders = (0, header_utils_1.pairFlatRawHeaders)(req.rawHeaders);
let socketMetadata = req.socket[socket_extensions_1.SocketMetadata];
// Make req.url always absolute, if it isn't already, using the host header.
// It might not be if this is a direct request, or if it's being transparently proxied.
if (!(0, url_1.isAbsoluteUrl)(req.url)) {
req.protocol = (0, header_utils_1.getHeaderValue)(rawHeaders, ':scheme') ||
(req.socket[socket_extensions_1.LastHopEncrypted] ? 'https' : 'http');
req.path = req.url;
const tunnelDestination = req.socket[socket_extensions_1.LastTunnelAddress]
? (0, url_1.getDestination)(req.protocol, req.socket[socket_extensions_1.LastTunnelAddress])
: undefined;
const isTunnelToIp = !!tunnelDestination && (0, ip_utils_1.isIP)(tunnelDestination.hostname);
const urlDestination = (0, url_1.getDestination)(req.protocol, (!isTunnelToIp
? (req.socket[socket_extensions_1.LastTunnelAddress] ?? // Tunnel domain name is preferred if available
(0, header_utils_1.getHeaderValue)(rawHeaders, ':authority') ??
(0, header_utils_1.getHeaderValue)(rawHeaders, 'host') ??
req.socket[socket_extensions_1.TlsMetadata]?.sniHostname)
: ((0, header_utils_1.getHeaderValue)(rawHeaders, ':authority') ??
(0, header_utils_1.getHeaderValue)(rawHeaders, 'host') ??
req.socket[socket_extensions_1.TlsMetadata]?.sniHostname ??
req.socket[socket_extensions_1.LastTunnelAddress] // We use the IP iff we have no hostname available at all
))
?? `localhost:${this.port}` // If you specify literally nothing, it's a direct request
);
// Actual destination always follows the tunnel - even if it's an IP
req.destination = tunnelDestination
?? urlDestination;
// URL port should always match the real port - even if (e.g) the Host header is lying.
urlDestination.port = req.destination.port;
const absoluteUrl = `${req.protocol}://${(0, url_1.normalizeHost)(req.protocol, `${urlDestination.hostname}:${urlDestination.port}`)}${req.path}`;
let effectiveUrl;
try {
effectiveUrl = new URL(absoluteUrl).toString();
}
catch (e) {
req.url = absoluteUrl;
throw e;
}
if (!(0, header_utils_1.getHeaderValue)(rawHeaders, ':path')) {
req.url = effectiveUrl;
}
else {
// Node's HTTP/2 compat logic maps .url to headers[':path']. We want them to
// diverge: .url should always be absolute, while :path may stay relative,
// so we override the built-in getter & setter:
Object.defineProperty(req, 'url', {
value: effectiveUrl
});
}
}
else {
// We have an absolute request. This is effectively a combined tunnel + end-server request,
// so we need to handle both of those, and hide the proxy-specific bits from later logic.
req.protocol = req.url.split('://', 1)[0];
req.path = (0, url_1.getPathFromAbsoluteUrl)(req.url);
req.destination = (0, url_1.getDestination)(req.protocol, req.socket[socket_extensions_1.LastTunnelAddress] ?? (0, url_1.getHostFromAbsoluteUrl)(req.url));
const proxyAuthHeader = (0, header_utils_1.getHeaderValue)(rawHeaders, 'proxy-authorization');
if (proxyAuthHeader) {
// Use this metadata for this request, but _only_ this request - it's not relevant
// to other requests on the same socket so we don't add it to req.socket.
socketMetadata = (0, socket_metadata_1.getSocketMetadataFromProxyAuth)(req.socket, proxyAuthHeader);
}
rawHeaders = rawHeaders.filter(([key]) => {
const lcKey = key.toLowerCase();
return lcKey !== 'proxy-connection' &&
lcKey !== 'proxy-authorization';
});
}
if (type === 'websocket') {
req.protocol = req.protocol === 'https'
? 'wss'
: 'ws';
// Transform the protocol in req.url too:
Object.defineProperty(req, 'url', {
value: req.url.replace(/^http/, 'ws')
});
}
const id = crypto.randomUUID();
const tags = (0, socket_metadata_1.getSocketMetadataTags)(socketMetadata);
const timingEvents = {
startTime: Date.now(),
startTimestamp: now()
};
req.on('end', () => {
timingEvents.bodyReceivedTimestamp || (timingEvents.bodyReceivedTimestamp = now());
});
const headers = (0, header_utils_1.rawHeadersToObject)(rawHeaders);
// Not writable for HTTP/2:
(0, util_2.makePropertyWritable)(req, 'headers');
(0, util_2.makePropertyWritable)(req, 'rawHeaders');
let rawTrailers;
Object.defineProperty(req, 'rawTrailers', {
get: () => rawTrailers,
set: (flatRawTrailers) => {
rawTrailers = flatRawTrailers
? (0, header_utils_1.pairFlatRawHeaders)(flatRawTrailers)
: undefined;
}
});
return Object.assign(req, {
id,
headers,
rawHeaders,
rawTrailers, // Just makes the type happy - really managed by property above
remoteIpAddress: req.socket.remoteAddress,
remotePort: req.socket.remotePort,
timingEvents,
tags
});
}
catch (e) {
const error = Object.assign(e, {
code: e.code ?? 'PREPROCESSING_FAILED',
badRequest: req
});
const h2Session = req.httpVersionMajor > 1 &&
req.stream?.session;
if (h2Session) {
this.handleInvalidHttp2Request(error, h2Session);
}
else {
this.handleInvalidHttp1Request(error, req.socket);
}
return null; // Null -> preprocessing failed, error already handled here
}
}
async handleRequest(rawRequest, rawResponse) {
const request = this.preprocessRequest(rawRequest, 'request');
if (request === null)
return; // Preprocessing failed - don't handle this
if (this.debug)
console.log(`Handling request for ${rawRequest.url}`);
let result = null;
const abort = (error) => {
if (result === null) {
result = 'aborted';
request.timingEvents.abortedTimestamp = now();
this.announceAbortAsync(request, error);
}
};
request.once('aborted', abort);
// In Node 16+ we don't get an abort event in many cases, just closes, but we know
// it's aborted because the response is closed with no other result being set.
rawResponse.once('close', () => setImmediate(abort));
request.once('error', (error) => setImmediate(() => abort(error)));
this.announceInitialRequestAsync(request);
const response = (0, request_utils_1.trackResponse)(rawResponse, request.timingEvents, request.tags, { maxSize: this.maxBodySize });
response.id = request.id;
response.on('error', (error) => {
console.log('Response error:', this.debug ? error : error.message);
abort(error);
});
try {
let nextRulePromise = this.findMatchingRule(this.requestRuleSets, request);
// Async: once we know what the next rule is, ping a request event
nextRulePromise
.then((rule) => rule ? rule.id : undefined)
.catch(() => undefined)
.then((ruleId) => {
request.matchedRuleId = ruleId;
this.announceCompletedRequestAsync(request);
});
let nextRule = await nextRulePromise;
if (nextRule) {
if (this.debug)
console.log(`Request matched rule: ${nextRule.explain()}`);
await nextRule.handle(request, response, {
record: this.recordTraffic,
debug: this.debug,
emitEventCallback: (this.eventEmitter.listenerCount('rule-event') !== 0)
? (type, event) => this.announceRuleEventAsync(request.id, nextRule.id, type, event)
: undefined
});
}
else {
await this.sendUnmatchedRequestError(request, response);
}
result = result || 'responded';
}
catch (e) {
if (e instanceof request_step_impls_1.AbortError) {
abort(e);
if (this.debug) {
console.error("Failed to handle request due to abort:", e);
}
}
else {
console.error("Failed to handle request:", this.debug
? e
: ((0, util_1.isErrorLike)(e) && e.message) || e);
// Do whatever we can to tell the client we broke
try {
response.writeHead(((0, util_1.isErrorLike)(e) && e.statusCode) || 500, ((0, util_1.isErrorLike)(e) && e.statusMessage) || 'Server error');
}
catch (e) { }
try {
response.end(((0, util_1.isErrorLike)(e) && e.toString()) || e);
result = result || 'responded';
}
catch (e) {
abort(e);
}
}
}
if (result === 'responded') {
this.announceResponseAsync(response);
}
}
async handleWebSocket(rawRequest, socket, head) {
const request = this.preprocessRequest(rawRequest, 'websocket');
if (request === null)
return; // Preprocessing failed - don't handle this
if (this.debug)
console.log(`Handling websocket for ${rawRequest.url}`);
socket.on('error', (error) => {
console.log('Response error:', this.debug ? error : error.message);
socket.destroy();
});
try {
let nextRulePromise = this.findMatchingRule(this.webSocketRuleSets, request);
// Async: once we know what the next rule is, ping a websocket-request event
nextRulePromise
.then((rule) => rule ? rule.id : undefined)
.catch(() => undefined)
.then((ruleId) => {
request.matchedRuleId = ruleId;
this.announceWebSocketRequestAsync(request);
});
this.trackWebSocketEvents(request, socket);
let nextRule = await nextRulePromise;
if (nextRule) {
if (this.debug)
console.log(`Websocket matched rule: ${nextRule.explain()}`);
await nextRule.handle(request, socket, head, {
record: this.recordTraffic,
debug: this.debug,
emitEventCallback: (this.eventEmitter.listenerCount('rule-event') !== 0)
? (type, event) => this.announceRuleEventAsync(request.id, nextRule.id, type, event)
: undefined
});
}
else {
await this.sendUnmatchedWebSocketError(request, socket, head);
}
}
catch (e) {
if (e instanceof request_step_impls_1.AbortError) {
if (this.debug) {
console.error("Failed to handle websocket due to abort:", e);
}
}
else {
console.error("Failed to handle websocket:", this.debug
? e
: ((0, util_1.isErrorLike)(e) && e.message) || e);
this.sendWebSocketErrorResponse(socket, e);
}
}
}
/**
* To match rules, we find the first rule (by priority then by set order) which matches and which is
* either not complete (has a completion check that's false) or which has no completion check defined
* and is the last option at that priority (i.e. by the last option at each priority repeats indefinitely.
*
* We move down the priority list only when either no rules match at all, or when all matching rules
* have explicit completion checks defined that are completed.
*/
async findMatchingRule(ruleSets, request) {
for (let ruleSet of Object.values(ruleSets).reverse()) { // Obj.values returns numeric keys in ascending order
// Start all rules matching immediately
const rulesMatches = ruleSet
.filter((r) => r.isComplete() !== true) // Skip all rules that are definitely completed
.map((r) => ({ rule: r, match: r.matches(request) }));
// Evaluate the matches one by one, and immediately use the first
for (let { rule, match } of rulesMatches) {
if (await match && rule.isComplete() === false) {
// The first matching incomplete rule we find is the one we should use
return rule;
}
}
// There are no incomplete & matching rules! One last option: if the last matching rule is
// maybe-incomplete (i.e. default completion status but has seen >0 requests) then it should
// match anyway. This allows us to add rules and have the last repeat indefinitely.
const lastMatchingRule = _.last(await (0, promise_1.filter)(rulesMatches, m => m.match))?.rule;
if (!lastMatchingRule || lastMatchingRule.isComplete())
continue; // On to lower priority matches
// Otherwise, must be a rule with isComplete === null, i.e. no specific completion check:
else
return lastMatchingRule;
}
return undefined; // There are zero valid matching rules at any priority, give up.
}
async getUnmatchedRequestExplanation(request) {
let requestExplanation = await this.explainRequest(request);
if (this.debug)
console.warn(`Unmatched request received: ${requestExplanation}`);
const requestRules = Object.values(this.requestRuleSets).flat();
const webSocketRules = Object.values(this.webSocketRuleSets).flat();
return `No rules were found matching this request.
This request was: ${requestExplanation}
${(requestRules.length > 0 || webSocketRules.length > 0)
? `The configured rules are:
${requestRules.map((rule) => rule.explain()).join("\n")}
${webSocketRules.map((rule) => rule.explain()).join("\n")}
`
: "There are no rules configured."}
${await this.suggestRule(request)}`;
}
async sendUnmatchedRequestError(request, response) {
response.setHeader('Content-Type', 'text/plain');
response.writeHead(503, "Request for unmocked endpoint");
response.end(await this.getUnmatchedRequestExplanation(request));
}
async sendUnmatchedWebSocketError(request, socket, head) {
const errorBody = await this.getUnmatchedRequestExplanation(request);
socket.on('error', () => { }); // Best efforts, we don't care about failures here.
socket.end([
'HTTP/1.1 503 Request for unmocked endpoint',
'Connection: close',
'Content-Type: text/plain'
].join('\r\n') +
'\r\n\r\n' +
errorBody);
socket.destroy();
}
async sendWebSocketErrorResponse(socket, error) {
if (socket.writable) {
socket.end('HTTP/1.1 500 Internal Server Error\r\n' +
'\r\n' +
((0, util_1.isErrorLike)(error)
? error.message ?? error.toString()
: ''));
}
socket.destroy(error);
}
async explainRequest(request) {
let msg = `${request.method} request to ${request.url}`;
let bodyText = await request.body.asText();
if (bodyText)
msg += ` with body \`${bodyText}\``;
if (!_.isEmpty(request.headers)) {
msg += ` with headers:\n${JSON.stringify(request.headers, null, 2)}`;
}
return msg;
}
async suggestRule(request) {
if (!this.suggestChanges)
return '';
let msg = "You can fix this by adding a rule to match this request, for example:\n";
msg += `mockServer.for${_.startCase(request.method.toLowerCase())}("${request.path}")`;
const contentType = request.headers['content-type'];
let isFormRequest = !!contentType && contentType.indexOf("application/x-www-form-urlencoded") > -1;
let formBody = await request.body.asFormData().catch(() => undefined);
if (isFormRequest && !!formBody) {
msg += `.withForm(${JSON.stringify(formBody)})`;
}
msg += '.thenReply(200, "your response");';
return msg;
}
// Called on server clientError, e.g. if the client disconnects during initial
// request data, or sends totally invalid gibberish. Only called for HTTP/1.1 errors.
handleInvalidHttp1Request(error, socket) {
if (socket[socket_extensions_1.ClientErrorInProgress]) {
// For subsequent errors on the same socket, accumulate packet data (linked to the socket)
// so that the error (probably delayed until next tick) has it all to work with
const previousPacket = socket[socket_extensions_1.ClientErrorInProgress].rawPacket;
const newPacket = error.rawPacket;
if (!newPacket || newPacket === previousPacket)
return;
if (previousPacket && previousPacket.length > 0) {
if (previousPacket.equals(newPacket.slice(0, previousPacket.length))) {
// This is the same data, but more - update the client error data
socket[socket_extensions_1.ClientErrorInProgress].rawPacket = newPacket;
}
else {
// This is different data for the same socket, probably an overflow, append it
socket[socket_extensions_1.ClientErrorInProgress].rawPacket = buffer_1.Buffer.concat([
previousPacket,
newPacket
]);
}
}
else {
// The first error had no data, we have data - use our data
socket[socket_extensions_1.ClientErrorInProgress].rawPacket = newPacket;
}
return;
}
// We can get multiple errors for the same socket in rapid succession as the parser works,
// so we store the initial buffer, wait a tick, and then reply/report the accumulated
// buffer from all errors together.
socket[socket_extensions_1.ClientErrorInProgress] = {
// We use HTTP peeked data to catch extra data the parser sees due to httpolyglot peeking,
// but which gets lost from the raw packet. If that data alone causes an error though
// (e.g. Q as first char) then this packet data does get thrown! Eugh. In that case,
// we need to avoid using both by accident, so we use just the non-peeked data instead
// if the initial data is _exactly_ identical.
rawPacket: error.rawPacket
};
setImmediate(async () => {
const errorCode = error.code;
const isHeaderOverflow = errorCode === "HPE_HEADER_OVERFLOW";
const commonParams = {
id: crypto.randomUUID(),
tags: [
`client-error:${error.code || 'UNKNOWN'}`,
...(0, socket_metadata_1.getSocketMetadataTags)(socket[socket_extensions_1.SocketMetadata])
],
timingEvents: { startTime: Date.now(), startTimestamp: now() }
};
const rawPacket = socket[socket_extensions_1.ClientErrorInProgress]?.rawPacket
?? buffer_1.Buffer.from([]);
// For packets where we get more than just httpolyglot-peeked data, guess-parse them:
const parsedRequest = error.badRequest ??
(rawPacket.byteLength > 1
? (0, request_utils_1.tryToParseHttpRequest)(rawPacket, socket)
: {});
if (isHeaderOverflow)
commonParams.tags.push('header-overflow');
const rawHeaders = parsedRequest.rawHeaders?.[0] && typeof parsedRequest.rawHeaders[0] === 'string'
? (0, header_utils_1.pairFlatRawHeaders)(parsedRequest.rawHeaders)
: parsedRequest.rawHeaders;
const request = {
...commonParams,
httpVersion: parsedRequest.httpVersion || '1.1',
method: parsedRequest.method,
protocol: parsedRequest.protocol,
url: parsedRequest.url,
path: parsedRequest.path,
headers: parsedRequest.headers || {},
rawHeaders: rawHeaders || [],
remoteIpAddress: socket.remoteAddress,
remotePort: socket.remotePort,
destination: parsedRequest.destination
};
let response;
if (socket.writable) {
response = {
...commonParams,
headers: { 'connection': 'close' },
rawHeaders: [['Connection', 'close']],
trailers: {},
rawTrailers: [],
statusCode: isHeaderOverflow
? 431
: 400,
statusMessage: isHeaderOverflow
? "Request Header Fields Too Large"
: "Bad Request",
body: (0, request_utils_1.buildBodyReader)(buffer_1.Buffer.from([]), {})
};
const responseBuffer = buffer_1.Buffer.from(`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n` +
"Connection: close\r\n\r\n", 'ascii');
// Wait for the write to complete before we destroy() below
await new Promise((resolve) => socket.write(responseBuffer, resolve));
commonParams.timingEvents.headersSentTimestamp = now();
commonParams.timingEvents.responseSentTimestamp = now();
}
else {
response = 'aborted';
commonParams.timingEvents.abortedTimestamp = now();
}
this.announceClientErrorAsync(socket, { errorCode, request, response });
socket.on('error', () => { }); // Just announce the error to listeners, don't actually die from it
socket.destroy(error);
});
}
// Handle HTTP/2 client errors. This is a work in progress, but usefully reports
// some of the most obvious cases.
handleInvalidHttp2Request(error, session) {
// Unlike with HTTP/1.1, we have no control of the actual handling of
// the error here, so this is just a matter of announcing the error to subscribers.
const socket = session.initialSocket;
const isTLS = socket instanceof tls.TLSSocket;
const isBadPreface = (error.errno === -903);
const rawHeaders = error.badRequest?.rawHeaders?.[0] && typeof error.badRequest?.rawHeaders[0] === 'string'
? (0, header_utils_1.pairFlatRawHeaders)(error.badRequest?.rawHeaders)
: error.badRequest?.rawHeaders;
this.announceClientErrorAsync(session.initialSocket, {
errorCode: error.code,
request: {
id: crypto.randomUUID(),
tags: [
`client-error:${error.code || 'UNKNOWN'}`,
...(isBadPreface ? ['client-error:bad-preface'] : []),
...(0, socket_metadata_1.getSocketMetadataTags)(socket?.[socket_extensions_1.SocketMetadata])
],
httpVersion: error.badRequest?.httpVersion ?? '2',
// Best guesses:
timingEvents: { startTime: Date.now(), startTimestamp: now() },
protocol: error.badRequest?.protocol || (isTLS ? "https" : "http"),
url: error.badRequest?.url ||
(isTLS ? `https://${socket.servername}/` : undefined),
path: error.badRequest?.path,
headers: error.badRequest?.headers || {},
rawHeaders: rawHeaders || [],
destination: error.badRequest?.destination
},
response: 'aborted' // These h2 errors get no app-level response, just a shutdown.
});
}
passthroughSocket(type, socket, hostname, port) {
const targetPort = port ?? 443; // Should only be undefined on SNI-only TLS passthrough
if ((0, socket_util_1.isSocketLoop)(this.outgoingPassthroughSockets, socket)) {
// Hard to reproduce: loops can only happen if a) SNI triggers this (because tunnels
// require a repeated client request at each step) and b) the hostname points back to
// us, and c) we're running on the default port. Still good to guard against though.
console.warn(`Socket bypass loop for ${hostname}:${targetPort}`);
(0, socket_util_1.resetOrDestroy)(socket);
return;
}
if (socket.closed)
return; // Nothing to do
let eventData = Object.assign(type === 'raw'
? (0, socket_util_1.buildRawSocketEventData)(socket)
: (0, socket_util_1.buildTlsSocketEventData)(socket), {
id: crypto.randomUUID(),
hostname: hostname, // Deprecated, but kept here for backward compat
destination: { hostname, port: targetPort }
});
setImmediate(() => this.eventEmitter.emit(`${type}-passthrough-opened`, eventData));
const upstreamSocket = net.connect({ host: hostname, port: targetPort });
upstreamSocket.setNoDelay(true);
socket.pipe(upstreamSocket);
upstreamSocket.pipe(socket);
if (type === 'raw') {
socket.on('data', (data) => {
const eventTimestamp = now();
setImmediate(() => {
this.eventEmitter.emit('raw-passthrough-data', {
id: eventData.id,
direction: 'received',
content: data,
eventTimestamp
});
});
});
upstreamSocket.on('data', (data) => {
const eventTimestamp = now();
setImmediate(() => {
this.eventEmitter.emit('raw-passthrough-data', {
id: eventData.id,
direction: 'sent',
content: data,
eventTimestamp
});
});
});
}
socket.on('error', () => upstreamSocket.destroy());
upstreamSocket.on('error', () => socket.destroy());
upstreamSocket.on('close', () => socket.destroy());
socket.on('close', () => {
upstreamSocket.destroy();
setImmediate(() => {
this.eventEmitter.emit(`${type}-passthrough-closed`, {
...eventData,
timingEvents: {
...eventData.timingEvents,
disconnectedTimestamp: now()
}
});
});
});
upstreamSocket.once('connect', () => this.outgoingPassthroughSockets.add(upstreamSocket));
upstreamSocket.once('close', () => this.outgoingPassthroughSockets.delete(upstreamSocket));
if (this.debug)
console.log(`Passing through bypassed ${type} connection to ${hostname}:${targetPort}${!port ? ' (assumed port)' : ''}`);
}
}
exports.MockttpServer = MockttpServer;
//# sourceMappingURL=mockttp-server.js.map