UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

989 lines (988 loc) 47.3 kB
"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