UNPKG

mockttp-mvs

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

420 lines 19.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseRawHttpResponse = exports.tryToParseHttpRequest = exports.waitForCompletedResponse = exports.trackResponse = exports.waitForCompletedRequest = exports.buildInitiatedRequest = exports.parseRequestBody = exports.buildBodyReader = exports.isMockttpBody = exports.decodeBodyBuffer = exports.encodeBodyBuffer = exports.isHttp2 = exports.validateHeader = exports.dropDefaultHeaders = exports.writeHead = exports.shouldKeepAlive = exports.getPathFromAbsoluteUrl = exports.getUrlWithoutProtocol = exports.isAbsoluteProtocollessUrl = exports.isRelativeUrl = exports.isAbsoluteUrl = void 0; const _ = require("lodash"); const tls_1 = require("tls"); const http = require("http"); const http2 = require("http2"); const stream = require("stream"); const querystring = require("querystring"); const now = require("performance-now"); const url = require("url"); const util_1 = require("./util"); const buffer_utils_1 = require("./buffer-utils"); const header_utils_1 = require("./header-utils"); // Is this URL fully qualified? // Note that this supports only HTTP - no websockets or anything else. const isAbsoluteUrl = (url) => url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://'); exports.isAbsoluteUrl = isAbsoluteUrl; const isRelativeUrl = (url) => url.startsWith('/'); exports.isRelativeUrl = isRelativeUrl; const isAbsoluteProtocollessUrl = (url) => !(0, exports.isAbsoluteUrl)(url) && !(0, exports.isRelativeUrl)(url); exports.isAbsoluteProtocollessUrl = isAbsoluteProtocollessUrl; const getUrlWithoutProtocol = (url) => { return url.split('://', 2).slice(-1).join(''); }; exports.getUrlWithoutProtocol = getUrlWithoutProtocol; const getPathFromAbsoluteUrl = (url) => { const pathIndex = (0, util_1.nthIndexOf)(url, '/', 3); if (pathIndex !== -1) { return url.slice(pathIndex); } else { return ''; } }; exports.getPathFromAbsoluteUrl = getPathFromAbsoluteUrl; const shouldKeepAlive = (req) => req.httpVersion !== '1.0' && req.headers['connection'] !== 'close' && req.headers['proxy-connection'] !== 'close'; exports.shouldKeepAlive = shouldKeepAlive; const writeHead = (response, status, statusMessage, headers) => { const flatHeaders = headers === undefined ? {} : isHttp2(response) // Due to a Node.js bug, H2 never expects flat headers ? headers : !Array.isArray(headers) ? (0, header_utils_1.objectHeadersToFlat)(headers) // RawHeaders for H1, must be flattened: : (0, header_utils_1.flattenPairedRawHeaders)(headers); // We aim to always pass flat headers to writeHead instead of calling setHeader because // in most cases it's more flexible about supporting raw data, e.g. multiple headers with // different casing can't be represented with setHeader at all (the latter overwrites). if (statusMessage === undefined) { response.writeHead(status, flatHeaders); } else { response.writeHead(status, statusMessage, flatHeaders); } }; exports.writeHead = writeHead; // If the user explicitly specifies headers, we tell Node not to handle them, // so the user-defined headers are the full set. function dropDefaultHeaders(response) { // Drop the default headers, so only the headers we explicitly configure are included [ 'connection', 'content-length', 'transfer-encoding', 'date' ].forEach((defaultHeader) => response.removeHeader(defaultHeader)); } exports.dropDefaultHeaders = dropDefaultHeaders; function validateHeader(name, value) { try { http.validateHeaderName(name); http.validateHeaderValue(name, value); return true; } catch (e) { return false; } } exports.validateHeader = validateHeader; function isHttp2(message) { return ('httpVersion' in message && !!message.httpVersion?.startsWith('2')) || // H2 request ('stream' in message && 'createPushResponse' in message); // H2 response } exports.isHttp2 = isHttp2; async function encodeBodyBuffer(buffer, headers) { const contentEncoding = headers['content-encoding']; // We skip encodeBuffer entirely if possible - this isn't strictly necessary, but it's useful // so you can drop the http-encoding package in bundling downstream without issue in cases // where you don't actually use any encodings. if (!contentEncoding) return buffer; return await (await Promise.resolve().then(() => require('http-encoding'))).encodeBuffer(buffer, contentEncoding, { level: 1 }); } exports.encodeBodyBuffer = encodeBodyBuffer; async function decodeBodyBuffer(buffer, headers) { const contentEncoding = headers['content-encoding']; // We skip decodeBuffer entirely if possible - this isn't strictly necessary, but it's useful // so you can drop the http-encoding package in bundling downstream without issue in cases // where you don't actually use any encodings. if (!contentEncoding) return buffer; return await (await Promise.resolve().then(() => require('http-encoding'))).decodeBuffer(buffer, contentEncoding); } exports.decodeBodyBuffer = decodeBodyBuffer; // Parse an in-progress request or response stream, i.e. where the body or possibly even the headers have // not been fully received/sent yet. const parseBodyStream = (bodyStream, maxSize, getHeaders) => { let bufferPromise = null; let completedBuffer = null; let body = { // Returns a stream for the full body, not the live streaming body. // Each call creates a new stream, which starts with the already seen // and buffered data, and then continues with the live stream, if active. // Listeners to this stream *must* be attached synchronously after this call. asStream() { return completedBuffer ? (0, buffer_utils_1.bufferToStream)(completedBuffer) : (0, buffer_utils_1.bufferThenStream)(body.asBuffer(), bodyStream); }, asBuffer() { if (!bufferPromise) { bufferPromise = (0, buffer_utils_1.streamToBuffer)(bodyStream, maxSize); bufferPromise .then((buffer) => completedBuffer = buffer) .catch(() => { }); // If we get no body, completedBuffer stays null } return bufferPromise; }, async asDecodedBuffer() { const buffer = await body.asBuffer(); return decodeBodyBuffer(buffer, getHeaders()); }, asText(encoding = 'utf8') { return body.asDecodedBuffer().then((b) => b.toString(encoding)); }, asJson() { return body.asText().then((t) => JSON.parse(t)); }, asFormData() { return body.asText().then((t) => querystring.parse(t)); }, }; return body; }; async function runAsyncOrUndefined(func) { try { return await func(); } catch { return undefined; } } const waitForBody = async (body, headers) => { const bufferBody = await body.asBuffer(); return (0, exports.buildBodyReader)(bufferBody, headers); }; const isMockttpBody = (body) => { return body.hasOwnProperty('getDecodedBuffer'); }; exports.isMockttpBody = isMockttpBody; const buildBodyReader = (body, headers) => { const completedBody = { buffer: body, async getDecodedBuffer() { return runAsyncOrUndefined(async () => (0, buffer_utils_1.asBuffer)(await decodeBodyBuffer(this.buffer, headers))); }, async getText() { return runAsyncOrUndefined(async () => (await this.getDecodedBuffer()).toString()); }, async getJson() { return runAsyncOrUndefined(async () => JSON.parse((await completedBody.getText()))); }, async getFormData() { return runAsyncOrUndefined(async () => { const text = await completedBody.getText(); return text ? querystring.parse(text) : undefined; }); } }; return completedBody; }; exports.buildBodyReader = buildBodyReader; const parseRequestBody = (req, options) => { let transformedRequest = req; transformedRequest.body = parseBodyStream(req, options.maxSize, () => req.headers); }; exports.parseRequestBody = parseRequestBody; /** * Build an initiated request: the external representation of a request * that's just started. */ function buildInitiatedRequest(request) { return { ..._.pick(request, 'id', 'matchedRuleId', 'protocol', 'httpVersion', 'method', 'url', 'path', 'remoteIpAddress', 'remotePort', 'hostname', 'headers', 'rawHeaders', 'tags'), timingEvents: request.timingEvents }; } exports.buildInitiatedRequest = buildInitiatedRequest; /** * Build a completed request: the external representation of a request * that's been completely received (but not necessarily replied to). */ async function waitForCompletedRequest(request) { const body = await waitForBody(request.body, request.headers); const requestData = buildInitiatedRequest(request); return { ...requestData, body }; } exports.waitForCompletedRequest = waitForCompletedRequest; function trackResponse(response, timingEvents, tags, options) { let trackedResponse = response; trackedResponse.timingEvents = timingEvents; trackedResponse.tags = tags; // Headers are sent when .writeHead or .write() are first called const trackingStream = new stream.PassThrough(); const originalWriteHeader = trackedResponse.writeHead; const originalWrite = trackedResponse.write; const originalEnd = trackedResponse.end; const originalGetHeaders = trackedResponse.getHeaders; let writtenHeaders; trackedResponse.getRawHeaders = () => writtenHeaders ?? []; trackedResponse.getHeaders = () => (0, header_utils_1.rawHeadersToObject)(trackedResponse.getRawHeaders()); trackedResponse.writeHead = function (...args) { if (!timingEvents.headersSentTimestamp) { timingEvents.headersSentTimestamp = now(); } // HTTP/2 responses shouldn't have a status message: if (isHttp2(trackedResponse) && typeof args[1] === 'string') { args[1] = undefined; } let headersArg; if (args[2]) { headersArg = args[2]; } else if (typeof args[1] !== 'string') { headersArg = args[1]; } // Two legal formats of header args (flat & object), one unofficial (tuple array) if (Array.isArray(headersArg)) { if (!Array.isArray(headersArg[0])) { // Flat -> Raw tuples writtenHeaders = (0, header_utils_1.pairFlatRawHeaders)(headersArg); } else { // Already raw tuples, cheeky writtenHeaders = headersArg; } } else { // Headers object -> raw tuples writtenHeaders = (0, header_utils_1.objectHeadersToRaw)(headersArg ?? {}); } // Headers might also have been set with setHeader before. They'll be combined, with headers // here taking precendence. We simulate this by pulling in all values from getHeaders() and // remembering any of those that we're not about to override. const storedHeaders = originalGetHeaders.apply(this); const writtenHeaderKeys = writtenHeaders.map(([key]) => key.toLowerCase()); const storedHeaderKeys = Object.keys(storedHeaders); if (storedHeaderKeys.length) { storedHeaderKeys .filter((key) => !writtenHeaderKeys.includes(key)) .reverse() // We're unshifting (these were set first) so we have to reverse to keep order. .forEach((key) => { const value = storedHeaders[key]; if (Array.isArray(value)) { value.reverse().forEach(v => writtenHeaders?.unshift([key, v])); } else if (value !== undefined) { writtenHeaders?.unshift([key, value]); } }); } return originalWriteHeader.apply(this, args); }; const trackingWrite = function (...args) { trackingStream.write.apply(trackingStream, args); return originalWrite.apply(this, args); }; trackedResponse.write = trackingWrite; trackedResponse.end = function (...args) { // We temporarily disable write tracking here, as .end // can call this.write, but that write should not be // tracked, or we'll get duplicate writes when trackingStream // calls it on itself too. trackedResponse.write = originalWrite; trackingStream.end.apply(trackingStream, args); let result = originalEnd.apply(this, args); trackedResponse.write = trackingWrite; return result; }; trackedResponse.body = parseBodyStream(trackingStream, options.maxSize, () => trackedResponse.getHeaders()); // Proxy errors (e.g. write-after-end) to the response, so they can be // handled elsewhere, rather than killing the process outright. trackingStream.on('error', (e) => trackedResponse.emit('error', e)); return trackedResponse; } exports.trackResponse = trackResponse; /** * Build a completed response: the external representation of a response * that's been completely written out and sent back to the client. */ async function waitForCompletedResponse(response) { // Ongoing response has 'getHeaders' - completed has 'headers'. if ('headers' in response) return response; const body = await waitForBody(response.body, response.getHeaders()); response.timingEvents.responseSentTimestamp = response.timingEvents.responseSentTimestamp || now(); const completedResponse = _(response).pick([ 'id', 'statusCode', 'timingEvents', 'tags' ]).assign({ statusMessage: '', headers: response.getHeaders(), rawHeaders: response.getRawHeaders(), body: body }).valueOf(); if (!(response instanceof http2.Http2ServerResponse)) { // H2 has no status messages, and generates a warning if you look for one completedResponse.statusMessage = response.statusMessage; } return completedResponse; } exports.waitForCompletedResponse = waitForCompletedResponse; // Take raw HTTP request bytes received, have a go at parsing something useful out of them. // Very lax - this is a method to use when normal parsing has failed, not as standard function tryToParseHttpRequest(input, socket) { const req = {}; try { req.protocol = socket.__lastHopEncrypted ? "https" : "http"; // Wild guess really // For TLS sockets, we default the hostname to the name given by SNI. Might be overridden // by the URL or Host header later, if available. if (socket instanceof tls_1.TLSSocket) req.hostname = socket.servername; const lines = (0, buffer_utils_1.splitBuffer)(input, '\r\n'); const requestLine = lines[0].slice(0, lines[0].length).toString('ascii'); const [method, rawUri, httpProtocol] = requestLine.split(" "); if (method) req.method = method.slice(0, 15); // With overflows this could be *anything*. Limit it slightly. // An empty line delineates the headers from the body const emptyLineIndex = _.findIndex(lines, (line) => line.length === 0); try { const headerLines = lines.slice(1, emptyLineIndex === -1 ? undefined : emptyLineIndex); const rawHeaders = headerLines .map((line) => (0, buffer_utils_1.splitBuffer)(line, ':', 2)) .filter((line) => line.length > 1) .map((headerParts) => headerParts.map(p => p.toString('utf8').trim())); req.rawHeaders = rawHeaders; req.headers = (0, header_utils_1.rawHeadersToObject)(rawHeaders); } catch (e) { } try { const parsedUrl = url.parse(rawUri); req.path = parsedUrl.path ?? undefined; const hostHeader = _.find(req.headers, (_value, key) => key.toLowerCase() === 'host'); if (hostHeader) { req.hostname = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; } else if (parsedUrl.hostname) { req.hostname = parsedUrl.hostname; } if (rawUri.includes('://') || !req.hostname) { // URI is absolute, or we have no way to guess the host at all req.url = rawUri; } else { // URI is relative (or invalid) and we have a host: use it req.url = `${req.protocol}://${req.hostname}${rawUri.startsWith('/') ? '' : '/' // Add a slash if the URI is garbage }${rawUri}`; } } catch (e) { } try { const httpVersion = httpProtocol.split('/')[1]; req.httpVersion = httpVersion; } catch (e) { } } catch (e) { } return req; } exports.tryToParseHttpRequest = tryToParseHttpRequest; // Take raw HTTP response bytes received, parse something useful out of them. This is *not* // very lax, and will throw errors due to unexpected response data, but it's used when we // ourselves generate the data (for websocket responses that 'ws' writes directly to the // socket invisibly). Fortunately all responses are very simple: function parseRawHttpResponse(input, request) { const { id, tags, timingEvents } = request; const lines = (0, buffer_utils_1.splitBuffer)(input, '\r\n'); const responseLine = lines[0].subarray(0, lines[0].length).toString('ascii'); const [_httpVersion, rawStatusCode, ...restResponseLine] = responseLine.split(" "); const statusCode = parseInt(rawStatusCode, 10); const statusMessage = restResponseLine.join(' '); // An empty line delineates the headers from the body const emptyLineIndex = _.findIndex(lines, (line) => line.length === 0); const headerLines = lines.slice(1, emptyLineIndex === -1 ? undefined : emptyLineIndex); const rawHeaders = headerLines .map((line) => (0, buffer_utils_1.splitBuffer)(line, ':', 2)) .map((headerParts) => headerParts.map(p => p.toString('utf8').trim())); const headers = (0, header_utils_1.rawHeadersToObject)(rawHeaders); const body = (0, exports.buildBodyReader)(Buffer.from([]), {}); return { id, tags, timingEvents, statusCode, statusMessage, rawHeaders, headers, body }; } exports.parseRawHttpResponse = parseRawHttpResponse; //# sourceMappingURL=request-utils.js.map