"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MockttpServer = void 0;
const _ = require("lodash");
const net = require("net");
const url = require("url");
const tls = require("tls");
const events_1 = require("events");
const portfinder = require("portfinder");
const connect = require("connect");
const uuid_1 = require("uuid");
const cors = require("cors");
const now = require("performance-now");
const async_mutex_1 = require("async-mutex");
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 error_1 = require("../util/error");
const util_1 = require("../util/util");
const url_1 = require("../util/url");
const socket_util_1 = require("../util/socket-util");
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_handlers_1 = require("../rules/requests/request-handlers");
const websocket_rule_1 = require("../rules/websockets/websocket-rule");
const websocket_handlers_1 = require("../rules/websockets/websocket-handlers");
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.maxBodySize = options.maxBodySize ?? Infinity;
        this.eventEmitter = new events_1.EventEmitter();
        this.defaultWsHandler = new websocket_handlers_1.RejectWebSocketHandler(503, "Request for unmocked endpoint");
        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,
        }, this.app, this.announceTlsErrorAsync.bind(this), this.passthroughSocket.bind(this));
        // 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.from([]);
        socket._writev = undefined;
        socket._write = function () {
            data = 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.tlsSetupCompleted)
            return;
        setImmediate(() => {
            // We can get falsey but set hostname values - drop them
            if (!request.hostname)
                delete request.hostname;
            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.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
            });
        });
    }
    preprocessRequest(req, type) {
        (0, request_utils_1.parseRequestBody)(req, { maxSize: this.maxBodySize });
        // 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 = req.headers[':scheme'] ||
                (req.socket.__lastHopEncrypted ? 'https' : 'http');
            req.path = req.url;
            const host = req.headers[':authority'] || req.headers['host'];
            const absoluteUrl = `${req.protocol}://${host}${req.path}`;
            if (!req.headers[':path']) {
                req.url = new url.URL(absoluteUrl).toString();
            }
            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: new url.URL(absoluteUrl).toString()
                });
            }
        }
        else {
            req.protocol = req.url.split('://', 1)[0];
            req.path = (0, url_1.getPathFromAbsoluteUrl)(req.url);
        }
        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 = (0, uuid_1.v4)();
        const tags = [];
        const timingEvents = {
            startTime: Date.now(),
            startTimestamp: now()
        };
        req.on('end', () => {
            timingEvents.bodyReceivedTimestamp || (timingEvents.bodyReceivedTimestamp = now());
        });
        const rawHeaders = (0, header_utils_1.pairFlatRawHeaders)(req.rawHeaders);
        const headers = (0, header_utils_1.rawHeadersToObject)(rawHeaders);
        // Not writable for HTTP/2:
        (0, util_1.makePropertyWritable)(req, 'headers');
        (0, util_1.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
        });
    }
    async handleRequest(rawRequest, rawResponse) {
        const request = this.preprocessRequest(rawRequest, 'request');
        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,
                    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_handlers_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, error_1.isErrorLike)(e) && e.message) || e);
                // Do whatever we can to tell the client we broke
                try {
                    response.writeHead(((0, error_1.isErrorLike)(e) && e.statusCode) || 500, ((0, error_1.isErrorLike)(e) && e.statusMessage) || 'Server error');
                }
                catch (e) { }
                try {
                    response.end(((0, error_1.isErrorLike)(e) && e.toString()) || e);
                    result = result || 'responded';
                }
                catch (e) {
                    abort(e);
                }
            }
        }
        if (result === 'responded') {
            this.announceResponseAsync(response);
        }
    }
    async handleWebSocket(rawRequest, socket, head) {
        if (this.debug)
            console.log(`Handling websocket for ${rawRequest.url}`);
        const request = this.preprocessRequest(rawRequest, 'websocket');
        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, this.recordTraffic);
            }
            else {
                // Unmatched requests get passed through untouched automatically. This exists for
                // historical/backward-compat reasons, to match the initial WS implementation, and
                // will probably be removed to match handleRequest in future.
                await this.defaultWsHandler.handle(request, socket, head);
            }
        }
        catch (e) {
            if (e instanceof request_handlers_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, error_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 sendWebSocketErrorResponse(socket, error) {
        if (socket.writable) {
            socket.end('HTTP/1.1 500 Internal Server Error\r\n' +
                '\r\n' +
                ((0, error_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.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.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.clientErrorInProgress.rawPacket = newPacket;
                }
                else {
                    // This is different data for the same socket, probably an overflow, append it
                    socket.clientErrorInProgress.rawPacket = Buffer.concat([
                        previousPacket,
                        newPacket
                    ]);
                }
            }
            else {
                // The first error had no data, we have data - use our data
                socket.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.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: (0, uuid_1.v4)(),
                tags: [`client-error:${error.code || 'UNKNOWN'}`],
                timingEvents: { startTime: Date.now(), startTimestamp: now() }
            };
            const rawPacket = socket.clientErrorInProgress?.rawPacket
                ?? Buffer.from([]);
            // For packets where we get more than just httpolyglot-peeked data, guess-parse them:
            const parsedRequest = rawPacket.byteLength > 1
                ? (0, request_utils_1.tryToParseHttpRequest)(rawPacket, socket)
                : {};
            if (isHeaderOverflow)
                commonParams.tags.push('header-overflow');
            const request = {
                ...commonParams,
                httpVersion: parsedRequest.httpVersion,
                method: parsedRequest.method,
                protocol: parsedRequest.protocol,
                url: parsedRequest.url,
                path: parsedRequest.path,
                headers: parsedRequest.headers || {},
                rawHeaders: parsedRequest.rawHeaders || [],
                remoteIpAddress: socket.remoteAddress,
                remotePort: socket.remotePort
            };
            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.from([]), {})
                };
                const responseBuffer = 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.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);
        this.announceClientErrorAsync(session.initialSocket, {
            errorCode: error.code,
            request: {
                id: (0, uuid_1.v4)(),
                tags: [
                    `client-error:${error.code || 'UNKNOWN'}`,
                    ...(isBadPreface ? ['client-error:bad-preface'] : [])
                ],
                httpVersion: '2',
                // Best guesses:
                timingEvents: { startTime: Date.now(), startTimestamp: now() },
                protocol: isTLS ? "https" : "http",
                url: isTLS ? `https://${socket.servername // Use the hostname from SNI
                }/` : undefined,
                // Unknowable:
                path: undefined,
                headers: {},
                rawHeaders: []
            },
            response: 'aborted' // These h2 errors get no app-level response, just a shutdown.
        });
    }
    passthroughSocket(socket, host, port) {
        const targetPort = port || 443;
        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 ${host}:${targetPort}`);
            (0, socket_util_1.resetOrDestroy)(socket);
            return;
        }
        if (socket.closed)
            return; // Nothing to do
        const eventData = (0, socket_util_1.buildSocketEventData)(socket);
        eventData.id = (0, uuid_1.v4)();
        eventData.hostname = host;
        eventData.upstreamPort = targetPort;
        setImmediate(() => this.eventEmitter.emit('tls-passthrough-opened', eventData));
        const upstreamSocket = net.connect({ host, port: targetPort });
        socket.pipe(upstreamSocket);
        upstreamSocket.pipe(socket);
        socket.on('error', () => upstreamSocket.destroy());
        upstreamSocket.on('error', () => socket.destroy());
        upstreamSocket.on('close', () => socket.destroy());
        socket.on('close', () => {
            upstreamSocket.destroy();
            setImmediate(() => {
                this.eventEmitter.emit('tls-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 raw bypassed connection to ${host}:${targetPort}${!port ? ' (assumed port)' : ''}`);
    }
}
exports.MockttpServer = MockttpServer;
//# sourceMappingURL=mockttp-server.js.map