mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
628 lines (536 loc) • 23.5 kB
text/typescript
import { Buffer } from 'buffer';
import * as stream from 'stream';
import * as net from 'net';
import { TLSSocket } from 'tls';
import * as querystring from 'querystring';
import * as url from 'url';
import * as http from 'http';
import * as http2 from 'http2';
import * as _ from 'lodash';
import * as multipart from 'parse-multipart-data';
import now = require("performance-now");
import type { SUPPORTED_ENCODING } from 'http-encoding';
import { MaybePromise } from '@httptoolkit/util';
import {
Headers,
OngoingRequest,
CompletedRequest,
OngoingResponse,
CompletedResponse,
OngoingBody,
CompletedBody,
TimingEvents,
InitiatedRequest,
RawHeaders,
Destination
} from "../types";
import {
bufferThenStream,
bufferToStream,
BufferInProgress,
splitBuffer,
streamToBuffer,
asBuffer
} from './buffer-utils';
import {
flattenPairedRawHeaders,
getHeaderValue,
objectHeadersToFlat,
objectHeadersToRaw,
pairFlatRawHeaders,
rawHeadersToObject
} from './header-utils';
import { LastHopEncrypted, LastTunnelAddress } from './socket-extensions';
import { getDestination, normalizeHost } from './url';
export const shouldKeepAlive = (req: OngoingRequest): boolean =>
req.httpVersion !== '1.0' &&
req.headers['connection'] !== 'close';
export const writeHead = (
response: http.ServerResponse | http2.Http2ServerResponse,
status: number,
statusMessage?: string | undefined,
headers?: Headers | RawHeaders | undefined
) => {
const flatHeaders: http.OutgoingHttpHeaders | string[] =
headers === undefined
? {}
: isHttp2(response) && Array.isArray(headers)
// H2 raw headers support is poor so we map to object here.
// We should revert to flat headers once the below is resolved in LTS:
// https://github.com/nodejs/node/issues/51402
? rawHeadersToObject(headers)
: isHttp2(response)
? headers as Headers // H2 supports object headers just fine
: !Array.isArray(headers)
? objectHeadersToFlat(headers)
// RawHeaders for H1, must be flattened:
: 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) {
// Cast is required as Node H2 types don't know about raw headers:
response.writeHead(status, flatHeaders as http.OutgoingHttpHeaders);
} else {
response.writeHead(status, statusMessage, flatHeaders as http.OutgoingHttpHeaders);
}
};
export function isHttp2(
message: | http.IncomingMessage
| http.ServerResponse
| http2.Http2ServerRequest
| http2.Http2ServerResponse
| OngoingRequest
| OngoingResponse
): message is http2.Http2ServerRequest | http2.Http2ServerResponse {
return ('httpVersion' in message && !!message.httpVersion?.startsWith('2')) || // H2 request
('stream' in message && 'createPushResponse' in message); // H2 response
}
export async function encodeBodyBuffer(buffer: Uint8Array, headers: Headers | RawHeaders) {
const contentEncoding = getHeaderValue(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 import('http-encoding')).encodeBuffer(
buffer,
contentEncoding as SUPPORTED_ENCODING,
{ level: 1 }
);
}
export async function decodeBodyBuffer(buffer: Buffer, headers: 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 || contentEncoding === 'identity') return buffer;
return await (await import('http-encoding')).decodeBuffer(
buffer,
contentEncoding as SUPPORTED_ENCODING
)
}
// 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: stream.Readable, maxSize: number, getHeaders: () => Headers): OngoingBody => {
let bufferPromise: BufferInProgress | null = null;
let completedBuffer: Buffer | null = 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() {
// If we've already buffered the whole body, just stream it out:
if (completedBuffer) return bufferToStream(completedBuffer);
// Otherwise, we want to start buffering now, and wrap that with
// a stream that can live-stream the buffered data on demand:
const buffer = body.asBuffer();
buffer.catch(() => {}); // Errors will be handled via the stream, so silence unhandled rejections here.
return bufferThenStream(buffer, bodyStream);
},
asBuffer() {
if (!bufferPromise) {
bufferPromise = 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: BufferEncoding = '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<R>(func: () => Promise<R>): Promise<R | undefined> {
try {
return await func();
} catch {
return undefined;
}
}
const waitForBody = async (body: OngoingBody, headers: Headers): Promise<CompletedBody> => {
const bufferBody = await body.asBuffer();
return buildBodyReader(bufferBody, headers);
};
export const isMockttpBody = (body: any): body is CompletedBody => {
return body.hasOwnProperty('getDecodedBuffer');
}
type BodyDecoder = (buffer: Buffer, headers: Headers) => MaybePromise<Buffer>;
export const buildBodyReader = (
body: Buffer,
headers: Headers,
bufferDecoder: BodyDecoder = decodeBodyBuffer
): CompletedBody => {
const completedBody = {
buffer: body,
async getDecodedBuffer() {
return runAsyncOrUndefined(async () =>
asBuffer(
await bufferDecoder(this.buffer, headers)
)
);
},
async getText() {
return runAsyncOrUndefined(async () =>
(await this.getDecodedBuffer())!.toString()
);
},
async getJson() {
return runAsyncOrUndefined(async () =>
JSON.parse((await completedBody.getText())!)
)
},
async getUrlEncodedFormData() {
return runAsyncOrUndefined(async () => {
const contentType = headers["content-type"];
if (contentType?.includes("multipart/form-data")) return; // Actively ignore multipart data - won't work as expected
const text = await completedBody.getText();
return text ? querystring.parse(text) : undefined;
});
},
async getMultipartFormData() {
return runAsyncOrUndefined(async () => {
const contentType = headers["content-type"];
if (!contentType?.includes("multipart/form-data")) return;
const boundary = contentType.match(/;\s*boundary=(\S+)/);
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#boundary
// `boundary` is required for multipart entities.
if (!boundary) return;
const decoded = await this.getDecodedBuffer();
if (!decoded) return;
return multipart.parse(decoded, boundary[1]);
});
},
async getFormData(): Promise<querystring.ParsedUrlQuery | undefined> {
return runAsyncOrUndefined(async () => {
// Return multi-part data if present, or fallback to default URL-encoded
// parsing for all other cases. Data is returned in the same format regardless.
const multiPartBody = await completedBody.getMultipartFormData();
if (multiPartBody) {
const formData: querystring.ParsedUrlQuery = {};
multiPartBody.forEach((part) => {
const name = part.name;
if (name === undefined) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_header_for_a_multipart_body,
// The header must include `name` property to identify the field name.
// So we ignore parts without a name, treating it as invalid multipart form data.
} else {
// We do not use `filename` or `type` here, because return value of `getFormData` must be string or string array.
const prevValue = formData[name];
if (prevValue === undefined) {
formData[name] = part.data.toString();
} else if (Array.isArray(prevValue)) {
prevValue.push(part.data.toString());
} else {
formData[name] = [prevValue, part.data.toString()];
}
}
});
return formData;
} else {
return completedBody.getUrlEncodedFormData();
}
});
}
};
return completedBody;
};
export const parseRequestBody = (
req: http.IncomingMessage | http2.Http2ServerRequest,
options: { maxSize: number }
) => {
let transformedRequest = req as any as OngoingRequest;
transformedRequest.body = parseBodyStream(req, options.maxSize, () => req.headers);
};
/**
* Build an initiated request: the external representation of a request
* that's just started.
*/
export function buildInitiatedRequest(request: OngoingRequest): InitiatedRequest {
return {
..._.pick(request,
'id',
'matchedRuleId',
'protocol',
'httpVersion',
'method',
'url',
'path',
'remoteIpAddress',
'remotePort',
'destination',
'headers',
'rawHeaders',
'tags'
),
timingEvents: request.timingEvents
};
}
/**
* Build a completed request: the external representation of a request
* that's been completely received (but not necessarily replied to).
*/
export async function waitForCompletedRequest(request: OngoingRequest): Promise<CompletedRequest> {
const body = await waitForBody(request.body, request.headers);
const requestData = buildInitiatedRequest(request);
return {
...requestData,
body,
rawTrailers: request.rawTrailers ?? [],
trailers: rawHeadersToObject(request.rawTrailers ?? [])
};
}
/**
* Parse the accepted format of the headers argument for writeHead and addTrailers
* into a single consistent paired-tuple format.
*/
const getHeaderPairsFromArgument = (headersArg: any) => {
// Two legal formats of header args (flat & object), one unofficial (tuple array)
if (Array.isArray(headersArg)) {
if (!Array.isArray(headersArg[0])) {
// Flat -> Raw tuples
return pairFlatRawHeaders(headersArg);
} else {
// Already raw tuples, cheeky
return headersArg;
}
} else {
// Headers object -> raw tuples
return objectHeadersToRaw(headersArg ?? {});
}
};
export function trackResponse(
response: http.ServerResponse,
timingEvents: TimingEvents,
tags: string[],
options: { maxSize: number }
): OngoingResponse {
let trackedResponse = <OngoingResponse> 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 originalAddTrailers = trackedResponse.addTrailers;
const originalGetHeaders = trackedResponse.getHeaders;
let writtenHeaders: RawHeaders | undefined;
trackedResponse.getRawHeaders = () => writtenHeaders ?? [];
trackedResponse.getHeaders = () => rawHeadersToObject(trackedResponse.getRawHeaders());
trackedResponse.writeHead = function (this: typeof trackedResponse, ...args: any) {
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: any;
if (args[2]) {
headersArg = args[2];
} else if (typeof args[1] !== 'string') {
headersArg = args[1];
}
writtenHeaders = getHeaderPairsFromArgument(headersArg);
if (isHttp2(trackedResponse)) {
writtenHeaders.unshift([':status', args[0].toString()]);
}
// 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);
};
let writtenTrailers: RawHeaders | undefined;
trackedResponse.getRawTrailers = () => writtenTrailers ?? [];
trackedResponse.addTrailers = function (this: typeof trackedResponse, ...args: any) {
const trailersArg = args[0];
writtenTrailers = getHeaderPairsFromArgument(trailersArg);
return originalAddTrailers.apply(this, args);
};
const trackingWrite = function (this: typeof trackedResponse, ...args: any) {
trackingStream.write.apply(trackingStream, args);
return originalWrite.apply(this, args);
};
trackedResponse.write = trackingWrite;
trackedResponse.end = function (...args: any) {
// 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;
}
/**
* Build a completed response: the external representation of a response
* that's been completely written out and sent back to the client.
*/
export async function waitForCompletedResponse(
response: OngoingResponse | CompletedResponse
): Promise<CompletedResponse> {
// 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: CompletedResponse = _(response).pick([
'id',
'statusCode',
'timingEvents',
'tags'
]).assign({
statusMessage: '',
headers: response.getHeaders(),
rawHeaders: response.getRawHeaders(),
body: body,
rawTrailers: response.getRawTrailers(),
trailers: rawHeadersToObject(response.getRawTrailers())
}).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;
}
// 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
export function tryToParseHttpRequest(input: Buffer, socket: net.Socket): PartiallyParsedHttpRequest {
const req: PartiallyParsedHttpRequest = {};
try {
req.protocol = socket[LastHopEncrypted] ? "https" : "http"; // Wild guess really
const targetHost = socket[LastTunnelAddress] ?? (socket as TLSSocket).servername;
req.destination = targetHost
? getDestination(req.protocol, targetHost)
: undefined;
const lines = splitBuffer(input, '\r\n');
const requestLine = lines[0].subarray(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) => splitBuffer(line, ':', 2))
.filter((line) => line.length > 1)
.map((headerParts) =>
headerParts.map(p => p.toString('utf8').trim()) as [string, string]
);
req.rawHeaders = rawHeaders;
req.headers = 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'
) as string | undefined;
if (!req.destination) {
if (hostHeader) {
req.destination = getDestination(req.protocol, hostHeader);
} else if (parsedUrl.hostname) {
req.destination = getDestination(req.protocol, parsedUrl.hostname);
}
}
if (rawUri.includes('://') || !req.destination) {
// URI is absolute, or we have no way to guess the host at all
req.url = rawUri;
} else {
const host = normalizeHost(req.protocol, `${req.destination.hostname}:${req.destination.port}`);
// URI is relative (or invalid) and we have a host: use it
req.url = `${req.protocol}://${host}${
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;
}
type PartiallyParsedHttpRequest = {
protocol?: string;
httpVersion?: string;
method?: string;
url?: string;
headers?: Headers;
rawHeaders?: RawHeaders;
destination?: Destination;
path?: string;
}
// 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:
export function parseRawHttpResponse(input: Buffer, request: OngoingRequest): CompletedResponse {
const { id, tags, timingEvents} = request;
const lines = 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) => splitBuffer(line, ':', 2))
.map((headerParts) =>
headerParts.map(p => p.toString('utf8').trim()) as [string, string]
);
const headers = rawHeadersToObject(rawHeaders);
const body = buildBodyReader(Buffer.from([]), {});
return {
id,
tags,
timingEvents,
statusCode,
statusMessage,
rawHeaders,
headers,
body,
rawTrailers: [],
trailers: {}
};
}