mockttp-mvs
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
1,111 lines (979 loc) • 43.4 kB
text/typescript
import _ = require('lodash');
import url = require('url');
import type * as net from 'net';
import { encode as encodeBase64 } from 'base64-arraybuffer';
import { Readable, Transform } from 'stream';
import { stripIndent } from 'common-tags';
import {
Headers,
CompletedRequest,
CompletedBody,
Explainable,
RawHeaders
} from "../../types";
import { MaybePromise, Replace } from '../../util/type-utils';
import { buildBodyReader } from '../../util/request-utils';
import { asBuffer } from '../../util/buffer-utils';
import {
Serializable,
ClientServerChannel,
serializeBuffer,
SerializedProxyConfig,
serializeProxyConfig
} from "../../serialization/serialization";
import {
withDeserializedBodyReader,
withSerializedCallbackBuffers
} from '../../serialization/body-serialization';
import { ProxyConfig } from '../proxy-config';
/*
This file defines request handler *definitions*, which includes everything necessary to define
and serialize a request handler's behaviour, but doesn't include the actual handling logic (which
lives in ./request-handlers instead). This is intended to allow tree-shaking in browser usage
or remote clients to import only the necessary code, with no need to include all the real
request-processing and handling code that is only used at HTTP-runtime, so isn't relevant when
defining rules.
Every RequestHandler extends its definition, simply adding a handle() method, which handles
requests according to the configuration, and adding a deserialize static method that takes
the serialized output from the serialize() methods defined here and creates a working handler.
*/
export interface RequestHandlerDefinition extends Explainable, Serializable {
type: keyof typeof HandlerDefinitionLookup;
}
export type SerializedBuffer = { type: 'Buffer', data: number[] };
/**
* Can be returned from callbacks to override parts of a request.
*
* All fields are optional, and omitted values will default to the original
* request value.
*/
export interface CallbackRequestResult {
/**
* A replacement HTTP method, capitalized.
*/
method?: string;
/**
* The full URL to send the request to. If set, this will redirect
* the request and automatically update the Host header accordingly,
* unless you also provide a `headers` value that includes a Host
* header, in which case that will take used as-is.
*/
url?: string;
/**
* The replacement HTTP headers, as an object of string keys and either
* single string or array of string values.
*/
headers?: Headers;
/**
* A string or buffer, which replaces the request body if set. This will
* be automatically content-encoded to match the Content-Encoding defined
* in your request headers.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
body?: string | Buffer | Uint8Array;
/**
* A buffer, which replaces the request body if set, which is sent exactly
* as is, and is not automatically encoded.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
rawBody?: Buffer | Uint8Array;
/**
* A JSON value, which will be stringified and send as a JSON-encoded
* request body. This will be automatically content-encoded to match
* the Content-Encoding defined in your request headers.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
json?: any;
/**
* A response: either a response object defining the fields of a response
* or the string 'close' to immediately close the connection.
*
* See {@link CallbackResponseMessageResult} for the possible fields that can
* be set to define the response.
*
* If set, the request will not be forwarded at all, and this will be used
* as the response to immediately return to the client (or for 'close', this
* will immediately close the connection to the client).
*/
response?: CallbackResponseResult;
}
export type CallbackResponseResult =
| CallbackResponseMessageResult
| 'close'
| 'reset';
/**
* Can be returned from callbacks to define parts of a response, or
* override parts when given an existing repsonse.
*
* All fields are optional, and omitted values will default to the original
* response value or a default value.
*/
export interface CallbackResponseMessageResult {
/**
* The response status code as a number.
*
* Defaults to 200 if not set.
*/
statusCode?: number;
/**
* Supported only for backward compatibility.
*
* @deprecated Use statusCode instead.
*/
status?: number;
/**
* The response status message, as a string. This is ignored for
* HTTP/2 responses.
*
* Defaults to the default status message for the status code if not set.
*/
statusMessage?: string;
/**
* The replacement HTTP headers, as an object of string keys and either
* single string or array of string values.
*
* Defaults to a minimum set of standard required headers if not set.
*/
headers?: Headers;
/**
* A string or buffer, which replaces the response body if set. This will
* be automatically encoded to match the Content-Encoding defined in your
* response headers.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* Defaults to empty.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
body?: string | Buffer | Uint8Array;
/**
* A buffer, which replaces the response body if set, which is sent exactly
* as is, and is not automatically encoded.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
rawBody?: Buffer | Uint8Array;
/**
* A JSON value, which will be stringified and send as a JSON-encoded
* request body. This will be automatically content-encoded to match the
* Content-Encoding defined in your response headers.
*
* If this is set, the Content-Length header will be automatically updated
* accordingly to match, unless you also provide a `headers` value that
* includes a Content-Length header, in which case that will take used
* as-is.
*
* You should only return one body field: either `body`, `rawBody` or
* `json`.
*/
json?: any;
}
function validateCustomHeaders(
originalHeaders: Headers,
modifiedHeaders: Headers | undefined,
headerWhitelist: readonly string[] = []
) {
if (!modifiedHeaders) return;
// We ignore most returned pseudo headers, so we error if you try to manually set them
const invalidHeaders = _(modifiedHeaders)
.pickBy((value, name) =>
name.toString().startsWith(':') &&
// We allow returning a preexisting header value - that's ignored
// silently, so that mutating & returning the provided headers is always safe.
value !== originalHeaders[name] &&
// In some cases, specific custom pseudoheaders may be allowed, e.g. requests
// can have custom :scheme and :authority headers set.
!headerWhitelist.includes(name)
)
.keys();
if (invalidHeaders.size() > 0) {
throw new Error(
`Cannot set custom ${invalidHeaders.join(', ')} pseudoheader values`
);
}
}
export class SimpleHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'simple';
constructor(
public status: number,
public statusMessage?: string,
public data?: string | Uint8Array | Buffer | SerializedBuffer,
public headers?: Headers
) {
super();
validateCustomHeaders({}, headers);
}
explain() {
return `respond with status ${this.status}` +
(this.statusMessage ? ` (${this.statusMessage})`: "") +
(this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") +
(this.data ? ` and body "${this.data}"` : "");
}
}
/**
* @internal
*/
export interface SerializedCallbackHandlerData {
type: string;
name?: string;
version?: number;
}
/**
* @internal
*/
export interface CallbackRequestMessage {
args: [
| Replace<CompletedRequest, { body: string }> // New format
| CompletedRequest // Old format with directly serialized body
];
}
export class CallbackHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'callback';
constructor(
public callback: (request: CompletedRequest) => MaybePromise<CallbackResponseResult>
) {
super();
}
explain() {
return 'respond using provided callback' + (this.callback.name ? ` (${this.callback.name})` : '');
}
/**
* @internal
*/
serialize(channel: ClientServerChannel): SerializedCallbackHandlerData {
channel.onRequest<
CallbackRequestMessage,
CallbackResponseResult
>(async (streamMsg) => {
const request = _.isString(streamMsg.args[0].body)
? withDeserializedBodyReader( // New format: body serialized as base64
streamMsg.args[0] as Replace<CompletedRequest, { body: string }>
)
: { // Backward compat: old fully-serialized format
...streamMsg.args[0],
body: buildBodyReader(streamMsg.args[0].body.buffer, streamMsg.args[0].headers)
};
const callbackResult = await this.callback.call(null, request);
if (typeof callbackResult === 'string') {
return callbackResult;
} else {
return withSerializedCallbackBuffers(callbackResult);
}
});
return { type: this.type, name: this.callback.name, version: 2 };
}
}
/**
* @internal
*/
export interface SerializedStreamHandlerData {
type: string;
status: number;
headers?: Headers;
};
interface StreamHandlerMessage {
event: 'data' | 'end' | 'close' | 'error';
content: StreamHandlerEventMessage;
}
type StreamHandlerEventMessage =
{ type: 'string', value: string } |
{ type: 'buffer', value: string } |
{ type: 'arraybuffer', value: string } |
{ type: 'nil' };
export class StreamHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'stream';
constructor(
public status: number,
public stream: Readable & { done?: true },
public headers?: Headers
) {
super();
validateCustomHeaders({}, headers);
}
explain() {
return `respond with status ${this.status}` +
(this.headers ? `, headers ${JSON.stringify(this.headers)},` : "") +
' and a stream of response data';
}
/**
* @internal
*/
serialize(channel: ClientServerChannel): SerializedStreamHandlerData {
const serializationStream = new Transform({
objectMode: true,
transform: function (this: Transform, chunk, _encoding, callback) {
let serializedEventData: StreamHandlerEventMessage | false =
_.isString(chunk) ? { type: 'string', value: chunk } :
_.isBuffer(chunk) ? { type: 'buffer', value: chunk.toString('base64') } :
(_.isArrayBuffer(chunk) || _.isTypedArray(chunk)) ? { type: 'arraybuffer', value: encodeBase64(<any> chunk) } :
_.isNil(chunk) && { type: 'nil' };
if (!serializedEventData) {
callback(new Error(`Can't serialize streamed value: ${chunk.toString()}. Streaming must output strings, buffers or array buffers`));
}
callback(undefined, <StreamHandlerMessage> {
event: 'data',
content: serializedEventData
});
},
flush: function(this: Transform, callback) {
this.push(<StreamHandlerMessage> {
event: 'end'
});
callback();
}
});
// When we get a ping from the server-side, pipe the real stream to serialize it and send the data across
channel.once('data', () => {
this.stream.pipe(serializationStream).pipe(channel, { end: false });
});
return { type: this.type, status: this.status, headers: this.headers };
}
}
export class FileHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'file';
constructor(
public status: number,
public statusMessage: string | undefined,
public filePath: string,
public headers?: Headers
) {
super();
validateCustomHeaders({}, headers);
}
explain() {
return `respond with status ${this.status}` +
(this.statusMessage ? ` (${this.statusMessage})`: "") +
(this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") +
(this.filePath ? ` and body from file ${this.filePath}` : "");
}
}
// This is different from CompletedResponse because CompletedResponse is a client request to Mockttp
// whereas this is a real response from an upstream server that we modify before forwarding.
// We aim for a similar shape, but they're not exactly the same.
export interface PassThroughResponse {
id: string;
statusCode: number;
statusMessage?: string;
headers: Headers;
rawHeaders: RawHeaders;
body: CompletedBody;
}
export interface ForwardingOptions {
targetHost: string,
// Should the host (H1) or :authority (H2) header be updated to match?
updateHostHeader?: true | false | string // Change automatically/ignore/change to custom value
}
export interface PassThroughLookupOptions {
/**
* The maximum time to cache a DNS response. Up to this limit,
* responses will be cached according to their own TTL. Defaults
* to Infinity.
*/
maxTtl?: number;
/**
* How long to cache a DNS ENODATA or ENOTFOUND response. Defaults
* to 0.15.
*/
errorTtl?: number;
/**
* The primary servers to use. DNS queries will be resolved against
* these servers first. If no data is available, queries will fall
* back to dns.lookup, and use the OS's default DNS servers.
*
* This defaults to dns.getServers().
*/
servers?: string[];
}
export interface PassThroughHandlerOptions {
/**
* The forwarding configuration for the passthrough rule.
* This generally shouldn't be used explicitly unless you're
* building rule data by hand. Instead, call `thenPassThrough`
* to send data directly or `thenForwardTo` with options to
* configure traffic forwarding.
*/
forwarding?: ForwardingOptions,
/**
* A list of hostnames for which server certificate and TLS version errors
* should be ignored (none, by default).
*
* If set to 'true', HTTPS errors will be ignored for all hosts. WARNING:
* Use this at your own risk. Setting this to `true` can open your
* application to MITM attacks and should never be used over any network
* that is not completed trusted end-to-end.
*/
ignoreHostHttpsErrors?: string[] | boolean;
/**
* An array of additional certificates, which should be trusted as certificate
* authorities for upstream hosts, in addition to Node.js's built-in certificate
* authorities.
*
* Each certificate should be an object with either a `cert` key and a string
* or buffer value containing the PEM certificate, or a `certPath` key and a
* string value containing the local path to the PEM certificate.
*/
trustAdditionalCAs?: Array<{ cert: string | Buffer } | { certPath: string }>;
/**
* A mapping of hosts to client certificates to use, in the form of
* `{ key, cert }` objects (none, by default)
*/
clientCertificateHostMap?: {
[host: string]: { pfx: Buffer, passphrase?: string }
};
/**
* Upstream proxy configuration: pass through requests via this proxy.
*
* If this is undefined, no proxy will be used. To configure a proxy
* provide either:
* - a ProxySettings object
* - a callback which will be called with an object containing the
* hostname, and must return a ProxySettings object or undefined.
* - an array of ProxySettings or callbacks. The array will be
* processed in order, and the first not-undefined ProxySettings
* found will be used.
*
* When using a remote client, this parameter or individual array
* values may be passed by reference, using the name of a rule
* parameter configured in the admin server.
*/
proxyConfig?: ProxyConfig;
/**
* Custom DNS options, to allow configuration of the resolver used
* when forwarding requests upstream. Passing any option switches
* from using node's default dns.lookup function to using the
* cacheable-lookup module, which will cache responses.
*/
lookupOptions?: PassThroughLookupOptions;
/**
* Whether to simulate connection errors back to the client.
*
* By default (in most cases - see below) when an upstream request fails
* outright a 502 "Bad Gateway" response is sent to the downstream client,
* explicitly indicating the failure and containing the error that caused
* the issue in the response body.
*
* Only in the case of upstream connection reset errors is a connection reset
* normally sent back downstream to existing clients (this behaviour exists
* for backward compatibility, and will change to match other error behaviour
* in a future version).
*
* When this option is set to `true`, low-level connection failures will
* always trigger a downstream connection close/reset, rather than a 502
* response.
*
* This includes DNS failures, TLS connection errors, TCP connection resets,
* etc (but not HTTP non-200 responses, which are still proxied as normal).
* This is less convenient for debugging in a testing environment or when
* using a proxy intentionally, but can be more accurate when trying to
* transparently proxy network traffic, errors and all.
*/
simulateConnectionErrors?: boolean;
/**
* A set of data to automatically transform a request. This includes properties
* to support many transformation common use cases.
*
* For advanced cases, a custom callback using beforeRequest can be used instead.
* Using this field however where possible is typically simpler, more declarative,
* and can be more performant. The two options are mutually exclusive: you cannot
* use both transformRequest and a beforeRequest callback.
*
* Only one transformation for each target (method, headers & body) can be
* specified. If more than one is specified then an error will be thrown when the
* rule is registered.
*/
transformRequest?: RequestTransform;
/**
* A set of data to automatically transform a response. This includes properties
* to support many transformation common use cases.
*
* For advanced cases, a custom callback using beforeResponse can be used instead.
* Using this field however where possible is typically simpler, more declarative,
* and can be more performant. The two options are mutually exclusive: you cannot
* use both transformResponse and a beforeResponse callback.
*
* Only one transformation for each target (status, headers & body) can be
* specified. If more than one is specified then an error will be thrown when the
* rule is registered.
*/
transformResponse?: ResponseTransform;
/**
* A callback that will be passed the full request before it is passed through,
* and which returns an object that defines how the the request content should
* be transformed before it's passed to the upstream server.
*
* The callback can return an object to define how the request should be changed.
* All fields on the object are optional, and returning undefined is equivalent
* to returning an empty object (transforming nothing).
*
* See {@link CallbackRequestResult} for the possible fields that can be set.
*/
beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult | void> | void;
/**
* A callback that will be passed the full response before it is passed through,
* and which returns a value that defines how the the response content should
* be transformed before it's returned to the client.
*
* The callback can either return an object to define how the response should be
* changed, or the string 'close' to immediately close the underlying connection.
*
* All fields on the object are optional, and returning undefined is equivalent
* to returning an empty object (transforming nothing).
*
* See {@link CallbackResponseMessageResult} for the possible fields that can be set.
*/
beforeResponse?: (res: PassThroughResponse) => MaybePromise<CallbackResponseResult | void> | void;
}
export interface RequestTransform {
/**
* A replacement HTTP method. Case insensitive.
*/
replaceMethod?: string;
/**
* A headers object which will be merged with the real request headers to add or
* replace values. Headers with undefined values will be removed.
*/
updateHeaders?: Headers;
/**
* A headers object which will completely replace the real request headers.
*/
replaceHeaders?: Headers;
/**
* A string or buffer that replaces the request body entirely.
*
* If this is specified, the upstream request will not wait for the original request
* body, so this may make responses faster than they would be otherwise given large
* request bodies or slow/streaming clients.
*/
replaceBody?: string | Uint8Array | Buffer;
/**
* The path to a file, which will be used to replace the request body entirely. The
* file will be re-read for each request, so the body will always reflect the latest
* file contents.
*
* If this is specified, the upstream request will not wait for the original request
* body, so this may make responses faster than they would be otherwise given large
* request bodies or slow/streaming clients.
*/
replaceBodyFromFile?: string;
/**
* A JSON object which will be merged with the real request body. Undefined values
* will be removed. Any requests which are received with an invalid JSON body that
* match this rule will fail.
*/
updateJsonBody?: {
[key: string]: any;
};
/**
* Perform a series of string match & replace operations on the request body.
*
* This parameter should be an array of pairs, which will be applied to the body
* decoded as a string like `body.replace(p1, p2)`, applied in the order provided.
* The first parameter can be either a string or RegExp to match, and the second
* must be a string to insert. The normal `str.replace` $ placeholders can be
* used in the second argument, so that e.g. $1 will insert the 1st matched group.
*/
matchReplaceBody?: Array<[string | RegExp, string]>;
}
export interface ResponseTransform {
/**
* A replacement response status code.
*/
replaceStatus?: number;
/**
* A headers object which will be merged with the real response headers to add or
* replace values. Headers with undefined values will be removed.
*/
updateHeaders?: Headers;
/**
* A headers object which will completely replace the real response headers.
*/
replaceHeaders?: Headers;
/**
* A string or buffer that replaces the response body entirely.
*
* If this is specified, the downstream response will not wait for the original response
* body, so this may make responses arrive faster than they would be otherwise given large
* response bodies or slow/streaming servers.
*/
replaceBody?: string | Uint8Array | Buffer;
/**
* The path to a file, which will be used to replace the response body entirely. The
* file will be re-read for each response, so the body will always reflect the latest
* file contents.
*
* If this is specified, the downstream response will not wait for the original response
* body, so this may make responses arrive faster than they would be otherwise given large
* response bodies or slow/streaming servers.
*/
replaceBodyFromFile?: string;
/**
* A JSON object which will be merged with the real response body. Undefined values
* will be removed. Any responses which are received with an invalid JSON body that
* match this rule will fail.
*/
updateJsonBody?: {
[key: string]: any;
};
/**
* Perform a series of string match & replace operations on the response body.
*
* This parameter should be an array of pairs, which will be applied to the body
* decoded as a string like `body.replace(p1, p2)`, applied in the order provided.
* The first parameter can be either a string or RegExp to match, and the second
* must be a string to insert. The normal `str.replace` $ placeholders can be
* used in the second argument, so that e.g. $1 will insert the 1st matched group.
*/
matchReplaceBody?: Array<[string | RegExp, string]>;
}
/**
* @internal
*/
export interface SerializedPassThroughData {
type: 'passthrough';
forwardToLocation?: string;
forwarding?: ForwardingOptions;
proxyConfig?: SerializedProxyConfig;
ignoreHostCertificateErrors?: string[] | boolean; // Doesn't match option name, backward compat
extraCACertificates?: Array<{ cert: string } | { certPath: string }>;
clientCertificateHostMap?: { [host: string]: { pfx: string, passphrase?: string } };
lookupOptions?: PassThroughLookupOptions;
simulateConnectionErrors?: boolean;
transformRequest?: Replace<RequestTransform, {
'replaceBody'?: string, // Serialized as base64 buffer
'updateHeaders'?: string, // // Serialized as a string to preserve undefined values
'updateJsonBody'?: string, // Serialized as a string to preserve undefined values
'matchReplaceBody'?: Array<[
string | { regexSource: string, flags: string }, // Regexes serialized
string
]>
}>,
transformResponse?: Replace<ResponseTransform, {
'replaceBody'?: string, // Serialized as base64 buffer
'updateHeaders'?: string, // // Serialized as a string to preserve undefined values
'updateJsonBody'?: string, // Serialized as a string to preserve undefined values
'matchReplaceBody'?: Array<[
string | { regexSource: string, flags: string }, // Regexes serialized
string
]>
}>,
hasBeforeRequestCallback?: boolean;
hasBeforeResponseCallback?: boolean;
}
/**
* @internal
*/
export interface BeforePassthroughRequestRequest {
args: [Replace<CompletedRequest, { body: string }>];
}
/**
* @internal
*/
export interface BeforePassthroughResponseRequest {
args: [Replace<PassThroughResponse, { body: string }>];
}
/**
* Used in merging as a marker for values to omit, because lodash ignores undefineds.
* @internal
*/
export const SERIALIZED_OMIT = "__mockttp__transform__omit__";
export class PassThroughHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'passthrough';
public readonly forwarding?: ForwardingOptions;
public readonly ignoreHostHttpsErrors: string[] | boolean = [];
public readonly clientCertificateHostMap: {
[host: string]: { pfx: Buffer, passphrase?: string }
};
public readonly extraCACertificates: Array<{ cert: string | Buffer } | { certPath: string }> = [];
public readonly transformRequest?: RequestTransform;
public readonly transformResponse?: ResponseTransform;
public readonly beforeRequest?: (req: CompletedRequest) =>
MaybePromise<CallbackRequestResult | void> | void;
public readonly beforeResponse?: (res: PassThroughResponse) =>
MaybePromise<CallbackResponseResult | void> | void;
public readonly proxyConfig?: ProxyConfig;
public readonly lookupOptions?: PassThroughLookupOptions;
public readonly simulateConnectionErrors: boolean;
// Used in subclass - awkwardly needs to be initialized here to ensure that its set when using a
// handler built from a definition. In future, we could improve this (compose instead of inheritance
// to better control handler construction?) but this will do for now.
protected outgoingSockets = new Set<net.Socket>();
constructor(options: PassThroughHandlerOptions = {}) {
super();
// If a location is provided, and it's not a bare hostname, it must be parseable
const { forwarding } = options;
if (forwarding && forwarding.targetHost.includes('/')) {
const { protocol, hostname, port, path } = url.parse(forwarding.targetHost);
if (path && path.trim() !== "/") {
const suggestion = url.format({ protocol, hostname, port }) ||
forwarding.targetHost.slice(0, forwarding.targetHost.indexOf('/'));
throw new Error(stripIndent`
URLs for forwarding cannot include a path, but "${forwarding.targetHost}" does. ${''
}Did you mean ${suggestion}?
`);
}
}
this.forwarding = forwarding;
this.ignoreHostHttpsErrors = options.ignoreHostHttpsErrors || [];
if (!Array.isArray(this.ignoreHostHttpsErrors) && typeof this.ignoreHostHttpsErrors !== 'boolean') {
throw new Error("ignoreHostHttpsErrors must be an array or a boolean");
}
this.lookupOptions = options.lookupOptions;
this.proxyConfig = options.proxyConfig;
this.simulateConnectionErrors = !!options.simulateConnectionErrors;
this.clientCertificateHostMap = options.clientCertificateHostMap || {};
this.extraCACertificates = options.trustAdditionalCAs || [];
if (options.beforeRequest && options.transformRequest && !_.isEmpty(options.transformRequest)) {
throw new Error("BeforeRequest and transformRequest options are mutually exclusive");
} else if (options.beforeRequest) {
this.beforeRequest = options.beforeRequest;
} else if (options.transformRequest) {
if ([
options.transformRequest.updateHeaders,
options.transformRequest.replaceHeaders
].filter(o => !!o).length > 1) {
throw new Error("Only one request header transform can be specified at a time");
}
if ([
options.transformRequest.replaceBody,
options.transformRequest.replaceBodyFromFile,
options.transformRequest.updateJsonBody,
options.transformRequest.matchReplaceBody
].filter(o => !!o).length > 1) {
throw new Error("Only one request body transform can be specified at a time");
}
this.transformRequest = options.transformRequest;
}
if (options.beforeResponse && options.transformResponse && !_.isEmpty(options.transformResponse)) {
throw new Error("BeforeResponse and transformResponse options are mutually exclusive");
} else if (options.beforeResponse) {
this.beforeResponse = options.beforeResponse;
} else if (options.transformResponse) {
if ([
options.transformResponse.updateHeaders,
options.transformResponse.replaceHeaders
].filter(o => !!o).length > 1) {
throw new Error("Only one response header transform can be specified at a time");
}
if ([
options.transformResponse.replaceBody,
options.transformResponse.replaceBodyFromFile,
options.transformResponse.updateJsonBody,
options.transformResponse.matchReplaceBody
].filter(o => !!o).length > 1) {
throw new Error("Only one response body transform can be specified at a time");
}
this.transformResponse = options.transformResponse;
}
}
explain() {
return this.forwarding
? `forward the request to ${this.forwarding.targetHost}`
: 'pass the request through to the target host';
}
/**
* @internal
*/
serialize(channel: ClientServerChannel): SerializedPassThroughData {
if (this.beforeRequest) {
channel.onRequest<
BeforePassthroughRequestRequest,
CallbackRequestResult | undefined
>('beforeRequest', async (req) => {
const callbackResult = await this.beforeRequest!(
withDeserializedBodyReader(req.args[0])
);
const serializedResult = callbackResult
? withSerializedCallbackBuffers(callbackResult)
: undefined;
if (serializedResult?.response && typeof serializedResult?.response !== 'string') {
serializedResult.response = withSerializedCallbackBuffers(serializedResult.response);
}
return serializedResult;
});
}
if (this.beforeResponse) {
channel.onRequest<
BeforePassthroughResponseRequest,
CallbackResponseResult | undefined
>('beforeResponse', async (req) => {
const callbackResult = await this.beforeResponse!(
withDeserializedBodyReader(req.args[0])
);
if (typeof callbackResult === 'string') {
return callbackResult;
} else if (callbackResult) {
return withSerializedCallbackBuffers(callbackResult);
} else {
return undefined;
}
});
}
return {
type: this.type,
...this.forwarding ? {
forwardToLocation: this.forwarding.targetHost,
forwarding: this.forwarding
} : {},
proxyConfig: serializeProxyConfig(this.proxyConfig, channel),
lookupOptions: this.lookupOptions,
simulateConnectionErrors: this.simulateConnectionErrors,
ignoreHostCertificateErrors: this.ignoreHostHttpsErrors,
extraCACertificates: this.extraCACertificates.map((certObject) => {
// We use toString to make sure that buffers always end up as
// as UTF-8 string, to avoid serialization issues. Strings are an
// easy safe format here, since it's really all just plain-text PEM
// under the hood.
if ('cert' in certObject) {
return { cert: certObject.cert.toString('utf8') }
} else {
return certObject;
}
}),
clientCertificateHostMap: _.mapValues(this.clientCertificateHostMap,
({ pfx, passphrase }) => ({ pfx: serializeBuffer(pfx), passphrase })
),
transformRequest: this.transformRequest ? {
...this.transformRequest,
// Body is always serialized as a base64 buffer:
replaceBody: !!this.transformRequest?.replaceBody
? serializeBuffer(asBuffer(this.transformRequest.replaceBody))
: undefined,
// Update objects need to capture undefined & null as distict values:
updateHeaders: !!this.transformRequest?.updateHeaders
? JSON.stringify(
this.transformRequest.updateHeaders,
(k, v) => v === undefined ? SERIALIZED_OMIT : v
)
: undefined,
updateJsonBody: !!this.transformRequest?.updateJsonBody
? JSON.stringify(
this.transformRequest.updateJsonBody,
(k, v) => v === undefined ? SERIALIZED_OMIT : v
)
: undefined,
matchReplaceBody: !!this.transformRequest?.matchReplaceBody
? this.transformRequest.matchReplaceBody.map(([match, result]) =>
[
_.isRegExp(match)
? { regexSource: match.source, flags: match.flags }
: match,
result
]
)
: undefined,
} : undefined,
transformResponse: this.transformResponse ? {
...this.transformResponse,
// Body is always serialized as a base64 buffer:
replaceBody: !!this.transformResponse?.replaceBody
? serializeBuffer(asBuffer(this.transformResponse.replaceBody))
: undefined,
// Update objects need to capture undefined & null as distict values:
updateHeaders: !!this.transformResponse?.updateHeaders
? JSON.stringify(
this.transformResponse.updateHeaders,
(k, v) => v === undefined ? SERIALIZED_OMIT : v
)
: undefined,
updateJsonBody: !!this.transformResponse?.updateJsonBody
? JSON.stringify(
this.transformResponse.updateJsonBody,
(k, v) => v === undefined ? SERIALIZED_OMIT : v
)
: undefined,
matchReplaceBody: !!this.transformResponse?.matchReplaceBody
? this.transformResponse.matchReplaceBody.map(([match, result]) =>
[
_.isRegExp(match)
? { regexSource: match.source, flags: match.flags }
: match,
result
]
)
: undefined,
} : undefined,
hasBeforeRequestCallback: !!this.beforeRequest,
hasBeforeResponseCallback: !!this.beforeResponse
};
}
}
export class CloseConnectionHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'close-connection';
explain() {
return 'close the connection';
}
}
export class ResetConnectionHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'reset-connection';
explain() {
return 'reset the connection';
}
}
export class TimeoutHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'timeout';
explain() {
return 'time out (never respond)';
}
}
export class JsonRpcResponseHandlerDefinition extends Serializable implements RequestHandlerDefinition {
readonly type = 'json-rpc-response';
constructor(
public readonly result:
| { result: any, error?: undefined }
| { error: any, result?: undefined }
) {
super();
if (!('result' in result) && !('error' in result)) {
throw new Error('JSON-RPC response must be either a result or an error');
}
}
explain() {
const resultType = 'result' in this.result
? 'result'
: 'error';
return `send a fixed JSON-RPC ${resultType} of ${JSON.stringify(this.result[resultType])}`;
}
}
export const HandlerDefinitionLookup = {
'simple': SimpleHandlerDefinition,
'callback': CallbackHandlerDefinition,
'stream': StreamHandlerDefinition,
'file': FileHandlerDefinition,
'passthrough': PassThroughHandlerDefinition,
'close-connection': CloseConnectionHandlerDefinition,
'reset-connection': ResetConnectionHandlerDefinition,
'timeout': TimeoutHandlerDefinition,
'json-rpc-response': JsonRpcResponseHandlerDefinition
}