You're Invited:Meet the Socket Team at BlackHat and DEF CON in Las Vegas, Aug 7-8.RSVP
Socket
Socket
Sign inDemoInstall

mockttp

Package Overview
Dependencies
Maintainers
1
Versions
122
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

Comparing version 0.16.1 to 0.17.0

dist/util/request-utils.d.ts

4

dist/client/mockttp-client.d.ts

@@ -22,3 +22,3 @@ /**

}
declare type SubscribableEvent = 'request' | 'response' | 'abort' | 'tlsClientError';
declare type SubscribableEvent = 'request-initiated' | 'request' | 'response' | 'abort' | 'tlsClientError';
/**

@@ -43,2 +43,3 @@ * A Mockttp implementation, controlling a remote Mockttp standalone server.

private typeHasField;
private typeHasInputField;
enableDebug(): void;

@@ -51,2 +52,3 @@ reset: () => Promise<boolean>;

private _addRules;
private _addRule;
on(event: SubscribableEvent, callback: (data: any) => void): Promise<void>;

@@ -53,0 +55,0 @@ private getEndpointData;

@@ -29,3 +29,3 @@ "use strict";

const mocked_endpoint_client_1 = require("./mocked-endpoint-client");
const request_utils_1 = require("../server/request-utils");
const request_utils_1 = require("../util/request-utils");
const introspection_query_1 = require("./introspection-query");

@@ -50,2 +50,3 @@ class ConnectionError extends typed_error_1.TypedError {

const SUBSCRIBABLE_EVENTS = [
'request-initiated',
'request',

@@ -82,2 +83,16 @@ 'response',

this._addRules = (rules, reset = false) => __awaiter(this, void 0, void 0, function* () {
// Backward compat: make Add/SetRules work with servers that only define reset & addRule (singular).
// Adds a small risk of odd behaviour in the gap between reset & all the rules being added, but it
// should be extremely brief, and no worse than existing behaviour for those server versions.
if (!this.typeHasField('Mutation', 'addRules')) {
if (reset)
yield this.reset();
// Sequentially add the rules:
return rules.reduce((acc, rule) => {
return acc.then((endpoints) => __awaiter(this, void 0, void 0, function* () {
endpoints.push(yield this._addRule(rule));
return endpoints;
}));
}, Promise.resolve([]));
}
const requestName = reset ? 'SetRules' : 'AddRules';

@@ -90,6 +105,26 @@ const mutationName = reset ? 'setRules' : 'addRules';

}`, {
newRules: rules.map((rule) => mock_rule_1.serializeRuleData(rule, this.mockServerStream))
newRules: rules.map((rule) => {
const serializedData = mock_rule_1.serializeRuleData(rule, this.mockServerStream);
if (!this.typeHasInputField('MockRule', 'id')) {
delete serializedData.id;
}
return serializedData;
})
})).rules.map(r => r.id);
return ruleIds.map(ruleId => new mocked_endpoint_client_1.MockedEndpointClient(ruleId, this.getEndpointData(ruleId)));
});
// Exists purely for backward compat with servers that don't support AddRules/SetRules.
this._addRule = (rule) => __awaiter(this, void 0, void 0, function* () {
const ruleData = mock_rule_1.serializeRuleData(rule, this.mockServerStream);
delete ruleData.id; // Old servers don't support sending ids.
const response = yield this.queryMockServer(`mutation AddRule($newRule: MockRule!) {
addRule(input: $newRule) {
id
}
}`, {
newRule: ruleData
});
const ruleId = response.addRule.id;
return new mocked_endpoint_client_1.MockedEndpointClient(ruleId, this.getEndpointData(ruleId));
});
this.getEndpointData = (ruleId) => () => __awaiter(this, void 0, void 0, function* () {

@@ -250,2 +285,8 @@ let result = yield this.queryMockServer(`query GetEndpointData($id: ID!) {

}
typeHasInputField(typeName, fieldName) {
const type = _.find(this.mockServerSchema.types, { name: typeName });
if (!type)
return false;
return !!_.find(type.inputFields, { name: fieldName });
}
enableDebug() {

@@ -265,13 +306,4 @@ throw new Error("Client-side debug info not implemented.");

on(event, callback) {
// Ignore unknown events
if (!_.includes(SUBSCRIBABLE_EVENTS, event))
return Promise.resolve();
// Ignore TLS error events, if not supported by the server
if (event === 'tlsClientError' &&
!this.typeHasField('Subscription', 'failedTlsRequest'))
return Promise.resolve();
const standaloneStreamServer = this.mockServerOptions.standaloneServerUrl.replace(/^http/, 'ws');
const url = `${standaloneStreamServer}/server/${this.port}/subscription`;
const client = new subscriptions_transport_ws_1.SubscriptionClient(url, {}, WebSocket);
const queryResultName = {
'request-initiated': 'requestInitiated',
request: 'requestReceived',

@@ -282,5 +314,29 @@ response: 'responseCompleted',

}[event];
// Ignore events unknown to either us or the server
if (!queryResultName ||
!this.typeHasField('Subscription', queryResultName))
return Promise.resolve();
const standaloneStreamServer = this.mockServerOptions.standaloneServerUrl.replace(/^http/, 'ws');
const url = `${standaloneStreamServer}/server/${this.port}/subscription`;
const client = new subscriptions_transport_ws_1.SubscriptionClient(url, {}, WebSocket);
// Note the typeHasField checks - these are a quick hack for backward compatibility,
// introspecting the server schema to avoid requesting fields that don't exist on old servers.
const query = {
'request-initiated': {
operationName: 'OnRequestInitiated',
query: `subscription OnRequestInitiated {
${queryResultName} {
id,
protocol,
method,
url,
path,
hostname,
headers,
timingEvents,
httpVersion
}
}`
},
request: {

@@ -291,2 +347,3 @@ operationName: 'OnRequest',

id,
${this.typeHasField('Request', 'matchedRuleId') ? 'matchedRuleId' : ''}
protocol,

@@ -293,0 +350,0 @@ method,

@@ -6,3 +6,3 @@ /**

import { Mockttp, MockttpOptions } from "./mockttp";
export { Method, OngoingRequest, CompletedRequest, CompletedResponse, MockedEndpoint } from "./types";
export { Method, InitiatedRequest, CompletedRequest, CompletedResponse, MockedEndpoint } from "./types";
export { Mockttp };

@@ -9,0 +9,0 @@ import * as matchers from './rules/matchers';

import MockRuleBuilder from "./rules/mock-rule-builder";
import { ProxyConfig, MockedEndpoint, CompletedRequest, CompletedResponse, TlsRequest } from "./types";
import { ProxyConfig, MockedEndpoint, CompletedRequest, CompletedResponse, TlsRequest, InitiatedRequest } from "./types";
import { MockRuleData } from "./rules/mock-rule";

@@ -79,2 +79,14 @@ import { CAOptions } from './util/tls';

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -86,2 +98,14 @@ get(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -93,2 +117,14 @@ post(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -100,2 +136,14 @@ put(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -107,2 +155,14 @@ delete(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -114,2 +174,14 @@ patch(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -121,3 +193,15 @@ head(url: string | RegExp): MockRuleBuilder;

* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*
* This can only be used if the `cors` option has been set to false.

@@ -135,3 +219,4 @@ *

/**
* Subscribe to hear about request details as they're received.
* Subscribe to hear about request details as soon as the initial request details
* (method, path & headers) are received, without waiting for the body.
*

@@ -145,2 +230,13 @@ * This is only useful in some niche use cases, such as logging all requests seen

*/
on(event: 'request-initiated', callback: (req: InitiatedRequest) => void): Promise<void>;
/**
* Subscribe to hear about request details once the request is fully received.
*
* This is only useful in some niche use cases, such as logging all requests seen
* by the server independently of the rules defined.
*
* The callback will be called asynchronously from request handling. This function
* returns a promise, and the callback is not guaranteed to be registered until
* the promise is resolved.
*/
on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;

@@ -159,3 +255,4 @@ /**

/**
* Subscribe to hear about requests that are aborted before the response is completed.
* Subscribe to hear about requests that are aborted before the request or
* response is fully completed.
*

@@ -169,3 +266,3 @@ * This is only useful in some niche use cases, such as logging all requests seen

*/
on(event: 'abort', callback: (req: CompletedRequest) => void): Promise<void>;
on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;
/**

@@ -172,0 +269,0 @@ * Subscribe to hear about requests that start a TLS handshake, but fail to complete it.

@@ -35,6 +35,7 @@ /**

status: number;
statusMessage?: string | undefined;
data?: string | Buffer | SerializedBuffer | undefined;
headers?: Headers | undefined;
readonly type = "simple";
constructor(status: number, data?: string | Buffer | SerializedBuffer | undefined, headers?: Headers | undefined);
constructor(status: number, statusMessage?: string | undefined, data?: string | Buffer | SerializedBuffer | undefined, headers?: Headers | undefined);
explain(): string;

@@ -97,6 +98,6 @@ handle(_request: OngoingRequest, response: OngoingResponse): Promise<void>;

readonly type = "passthrough";
private forwardToLocation?;
private ignoreHostCertificateErrors;
private beforeRequest?;
private beforeResponse?;
readonly forwardToLocation?: string;
readonly ignoreHostCertificateErrors: string[];
readonly beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult>;
readonly beforeResponse?: (res: PassThroughResponse) => MaybePromise<CallbackResponseResult>;
constructor(options?: PassThroughHandlerOptions, forwardToLocation?: string);

@@ -103,0 +104,0 @@ explain(): string;

@@ -21,3 +21,3 @@ "use strict";

const common_tags_1 = require("common-tags");
const request_utils_1 = require("../server/request-utils");
const request_utils_1 = require("../util/request-utils");
const socket_util_1 = require("../util/socket-util");

@@ -29,5 +29,6 @@ const serialization_1 = require("../util/serialization");

class SimpleHandler extends serialization_1.Serializable {
constructor(status, data, headers) {
constructor(status, statusMessage, data, headers) {
super();
this.status = status;
this.statusMessage = statusMessage;
this.data = data;

@@ -39,2 +40,3 @@ this.headers = headers;

return `respond with status ${this.status}` +
(this.statusMessage ? ` (${this.statusMessage})` : "") +
(this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") +

@@ -48,3 +50,3 @@ (this.data ? ` and body "${this.data}"` : "");

}
response.writeHead(this.status);
response.writeHead(this.status, this.statusMessage);
if (isSerializedBuffer(this.data)) {

@@ -294,2 +296,13 @@ this.data = new Buffer(this.data);

this.ignoreHostCertificateErrors = [];
// If a location is provided, and it's not a bare hostname, it must be parseable
if (forwardToLocation && forwardToLocation.includes('/')) {
const { protocol, hostname, port, path } = url.parse(forwardToLocation);
if (path && path.trim() !== "/") {
const suggestion = url.format({ protocol, hostname, port }) ||
forwardToLocation.slice(0, forwardToLocation.indexOf('/'));
throw new Error(common_tags_1.stripIndent `
URLs passed to thenForwardTo cannot include a path, but "${forwardToLocation}" does. ${''}Did you mean ${suggestion}?
`);
}
}
this.forwardToLocation = forwardToLocation;

@@ -311,12 +324,14 @@ this.ignoreHostCertificateErrors = options.ignoreHostCertificateErrors || [];

if (this.forwardToLocation) {
// Forward to location overrides the host only, not the path
({ protocol, hostname, port } = url.parse(this.forwardToLocation));
headers['host'] = `${hostname}:${port}`;
if (!this.forwardToLocation.includes('/')) {
// We're forwarding to a bare hostname
[hostname, port] = this.forwardToLocation.split(':');
}
else {
// We're forwarding to a fully specified URL; override the host etc, but never the path.
({ protocol, hostname, port } = url.parse(this.forwardToLocation));
}
headers['host'] = hostname + (port ? `:${port}` : '');
}
// Check if this request is a request loop:
const socket = clientReq.socket;
// If it's ipv4 masquerading as v6, strip back to ipv4
const remoteAddress = socket.remoteAddress.replace(/^::ffff:/, '');
const remotePort = port ? Number.parseInt(port) : socket.remotePort;
if (isRequestLoop(remoteAddress, remotePort)) {
if (isRequestLoop(clientReq.socket)) {
throw new Error(common_tags_1.oneLine `

@@ -384,3 +399,2 @@ Passthrough loop detected. This probably means you're sending a request directly

: undefined;
let outgoingPort = null;
return new Promise((resolve, reject) => {

@@ -404,10 +418,17 @@ let serverReq = makeRequest({

if (this.beforeResponse) {
const body = yield request_utils_1.streamToBuffer(serverRes);
const modifiedRes = yield this.beforeResponse({
id: clientReq.id,
statusCode: serverStatusCode,
statusMessage: serverRes.statusMessage,
headers: serverHeaders,
body: request_utils_1.buildBodyReader(body, serverHeaders)
});
let modifiedRes;
try {
const body = yield request_utils_1.streamToBuffer(serverRes);
modifiedRes = yield this.beforeResponse({
id: clientReq.id,
statusCode: serverStatusCode,
statusMessage: serverRes.statusMessage,
headers: serverHeaders,
body: request_utils_1.buildBodyReader(body, serverHeaders)
});
}
catch (e) {
serverReq.abort();
return reject(e);
}
serverStatusCode = modifiedRes.statusCode ||

@@ -457,13 +478,11 @@ modifiedRes.status ||

serverReq.once('socket', (socket) => {
// We want the local port - it's not available until we actually connect
socket.once('connect', () => {
// Add this port to our list of active ports
outgoingPort = socket.localPort;
currentlyForwardingPorts.push(outgoingPort);
});
socket.once('close', () => {
// Remove this port from our list of active ports
currentlyForwardingPorts = currentlyForwardingPorts.filter((port) => port !== outgoingPort);
outgoingPort = null;
});
// This event can fire multiple times for keep-alive sockets, which are used to
// make multiple requests. If/when that happens, we don't need more event listeners.
if (currentlyForwardingSockets.has(socket))
return;
// Add this port to our list of active ports, once it's connected (before then it has no port)
socket.once('connect', () => currentlyForwardingSockets.add(socket));
// Remove this port from our list of active ports when it's closed
// This is called for both clean closes & errors.
socket.once('close', () => currentlyForwardingSockets.delete(socket));
});

@@ -505,2 +524,3 @@ if (reqBodyOverride) {

forwardToLocation: this.forwardToLocation,
ignoreHostCertificateErrors: this.ignoreHostCertificateErrors,
hasBeforeRequestCallback: !!this.beforeRequest,

@@ -575,9 +595,18 @@ hasBeforeResponseCallback: !!this.beforeResponse

};
// Passthrough handlers need to spot loops - tracking ongoing request ports and the local machine's
// ip lets us get pretty close to doing that (for 1 step loops, at least):
// Track currently live ports for forwarded connections, so we can spot requests from them later.
let currentlyForwardingPorts = [];
const isRequestLoop = (remoteAddress, remotePort) =>
// If the request is local, and from a port we're sending a request on right now, we have a loop
_.includes(socket_util_1.localAddresses, remoteAddress) && _.includes(currentlyForwardingPorts, remotePort);
// Passthrough handlers need to spot loops - tracking ongoing sockets lets us get pretty
// close to doing that (for 1 step loops, at least):
// We keep a list of all currently active outgoing sockets.
const currentlyForwardingSockets = new Set();
// We need to normalize ips for comparison, because the same ip may be reported as ::ffff:127.0.0.1
// and 127.0.0.1 on the two sides of the connection, for the same ip.
const normalizeIp = (ip) => (ip && ip.startsWith('::ffff:'))
? ip.slice('::ffff:'.length)
: ip;
// For incoming requests, compare the address & port: if they match, we've almost certainly got a loop.
// I don't think it's generally possible to see the same ip on different interfaces from one process (you need
// ip-netns network namespaces), but if it is, then there's a tiny chance of false positives here. If we have ip X,
// and on another interface somebody else has ip X, and the send a request with the same incoming port as an
// outgoing request we have on the other interface, we'll assume it's a loop. Extremely unlikely imo.
const isRequestLoop = (incomingSocket) => _.some([...currentlyForwardingSockets], (outgoingSocket) => normalizeIp(outgoingSocket.localAddress) === normalizeIp(incomingSocket.remoteAddress) &&
outgoingSocket.remotePort === incomingSocket.localPort);
//# sourceMappingURL=handlers.js.map

@@ -5,3 +5,3 @@ /**

import { OngoingRequest, Method, Explainable } from "../types";
import { Serializable } from "../util/serialization";
import { Serializable, ClientServerChannel } from "../util/serialization";
import { MaybePromise } from '../util/type-utils';

@@ -27,13 +27,18 @@ export interface RequestMatcher extends Explainable, Serializable {

readonly type = "simple-path";
private normalizedUrl;
constructor(path: string);
matches(request: OngoingRequest): boolean;
explain(): string;
serialize(channel: ClientServerChannel): {
normalizedUrl: string;
};
}
export declare class RegexPathMatcher extends Serializable implements RequestMatcher {
readonly type = "regex-path";
readonly regexString: string;
readonly regexSource: string;
constructor(regex: RegExp);
matches(request: OngoingRequest): boolean;
explain(): string;
serialize(channel: ClientServerChannel): {
regexString: string;
};
}

@@ -51,2 +56,9 @@ export declare class HeaderMatcher extends Serializable implements RequestMatcher {

}
export declare class ExactQueryMatcher extends Serializable implements RequestMatcher {
query: string;
readonly type = "exact-query-string";
constructor(query: string);
matches(request: OngoingRequest): boolean;
explain(): string;
}
export declare class QueryMatcher extends Serializable implements RequestMatcher {

@@ -120,2 +132,3 @@ readonly type = "query";

'query': typeof QueryMatcher;
'exact-query-string': typeof ExactQueryMatcher;
'form-data': typeof FormDataMatcher;

@@ -122,0 +135,0 @@ 'raw-body': typeof RawBodyMatcher;

@@ -18,2 +18,3 @@ "use strict";

const types_1 = require("../types");
const request_utils_1 = require("../util/request-utils");
const serialization_1 = require("../util/serialization");

@@ -56,12 +57,30 @@ const normalize_url_1 = require("../util/normalize-url");

this.type = 'simple-path';
if (!this.path)
throw new Error('Invalid URL: URL to match must not be empty');
let { search, query } = url.parse(this.path, true);
if (search) {
throw new Error(common_tags_1.stripIndent `
Tried to match a path that contained a query (${search}). ${''}To match query parameters, add .withQuery(${JSON.stringify(query)}) instead.
throw new Error(common_tags_1.oneLine `
Tried to match a path that contained a query (${search}).
To match query parameters, use .withQuery(${JSON.stringify(query)}) instead,
or .withExactQuery('${search}') to match this exact query string.
`);
}
this.normalizedUrl = normalize_url_1.default(this.path);
normalize_url_1.normalizeUrl(this.path); // Fail if URL can't be normalized
}
matches(request) {
return request.normalizedUrl === this.normalizedUrl;
const expectedUrl = normalize_url_1.normalizeUrl(this.path);
const reqUrl = normalize_url_1.normalizeUrl(request.url);
// reqUrl is always absolute, expectedUrl can be absolute, relative or protocolless-absolute
if (request_utils_1.isRelativeUrl(expectedUrl)) {
// Match the path only, for any host
return request_utils_1.getPathFromAbsoluteUrl(reqUrl) === expectedUrl;
}
else if (request_utils_1.isAbsoluteUrl(expectedUrl)) {
// Full absolute URL: match everything
return reqUrl === expectedUrl;
}
else {
// Absolute URL with no protocol
return request_utils_1.getUrlWithoutProtocol(reqUrl) === expectedUrl;
}
}

@@ -71,2 +90,8 @@ explain() {

}
serialize(channel) {
return Object.assign(super.serialize(channel), {
// For backward compat, will used by older (<0.17) servers
normalizedUrl: normalize_url_1.legacyNormalizeUrl(this.path)
});
}
}

@@ -78,11 +103,30 @@ exports.SimplePathMatcher = SimplePathMatcher;

this.type = 'regex-path';
this.regexString = regex.source;
this.regexSource = regex.source;
}
matches(request) {
let urlMatcher = new RegExp(this.regexString);
return urlMatcher.test(request.normalizedUrl);
if (this.regexSource !== undefined) {
const absoluteUrl = normalize_url_1.normalizeUrl(request.url);
const urlPath = request_utils_1.getPathFromAbsoluteUrl(absoluteUrl);
// Test the matcher against both the path alone & the full URL
const urlMatcher = new RegExp(this.regexSource);
return urlMatcher.test(absoluteUrl) ||
urlMatcher.test(urlPath);
}
else {
const { regexString } = this;
// Old client, use old normalization & logic. Without this, old clients that check
// example.com$ will fail to match (they should check ...com/$)
let urlMatcher = new RegExp(regexString);
return urlMatcher.test(normalize_url_1.legacyNormalizeUrl(request.url));
}
}
explain() {
return `matching /${unescapeRegexp(this.regexString)}/`;
return `matching /${unescapeRegexp(this.regexSource)}/`;
}
serialize(channel) {
return Object.assign(super.serialize(channel), {
// Backward compat for old servers
regexString: this.regexSource
});
}
}

@@ -104,2 +148,22 @@ exports.RegexPathMatcher = RegexPathMatcher;

exports.HeaderMatcher = HeaderMatcher;
class ExactQueryMatcher extends serialization_1.Serializable {
constructor(query) {
super();
this.query = query;
this.type = 'exact-query-string';
if (query !== '' && query[0] !== '?') {
throw new Error('Exact query matches must start with ?, or be empty');
}
}
matches(request) {
const { search } = url.parse(request.url);
return this.query === search || (!search && !this.query);
}
explain() {
return this.query
? `with a query exactly matching \`${this.query}\``
: 'with no query string';
}
}
exports.ExactQueryMatcher = ExactQueryMatcher;
class QueryMatcher extends serialization_1.Serializable {

@@ -242,2 +306,3 @@ constructor(queryObjectInput) {

'query': QueryMatcher,
'exact-query-string': ExactQueryMatcher,
'form-data': FormDataMatcher,

@@ -244,0 +309,0 @@ 'raw-body': RawBodyMatcher,

@@ -40,3 +40,3 @@ /**

/**
* Match only requests that include the given headers
* Match only requests that include the given headers.
*/

@@ -47,3 +47,3 @@ withHeaders(headers: {

/**
* Match only requests that include the given query parameters
* Match only requests that include the given query parameters.
*/

@@ -54,4 +54,9 @@ withQuery(query: {

/**
* Match only requests whose bodies include the given form data
* Match only requests that include the exact query string provided.
* The query string must start with a ? or be entirely empty.
*/
withExactQuery(query: string): MockRuleBuilder;
/**
* Match only requests whose bodies include the given form data.
*/
withForm(formData: {

@@ -111,5 +116,9 @@ [key: string]: string;

/**
* Reply to matched with with given status and (optionally) body
* and headers.
* Reply to matched with with given status code and (optionally) status message,
* body and headers.
*
* If one string argument is provided, it's used as the body. If two are
* provided (even if one is empty), then 1st is the status message, and
* the 2nd the body.
*
* Calling this method registers the rule with the server, so it

@@ -124,2 +133,3 @@ * starts to handle requests.

thenReply(status: number, data?: string | Buffer, headers?: Headers): Promise<MockedEndpoint>;
thenReply(status: number, statusMessage: string, data: string | Buffer, headers?: Headers): Promise<MockedEndpoint>;
/**

@@ -239,2 +249,7 @@ * Reply to matched requests with the given status & JSON and (optionally)

*
* The url may optionally contain a protocol. If it does, it will override
* the protocol (and potentially the port, if unspecified) of the request.
* If no protocol is specified, the protocol (and potentially the port)
* of the original request URL will be used instead.
*
* This method also takes options to configure how the request is passed

@@ -241,0 +256,0 @@ * through. The only option currently supported is ignoreHostCertificateErrors,

@@ -14,5 +14,3 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
const url = require("url");
const lodash_1 = require("lodash");
const common_tags_1 = require("common-tags");
const completion_checkers_1 = require("./completion-checkers");

@@ -63,3 +61,3 @@ const matchers_1 = require("./matchers");

/**
* Match only requests that include the given headers
* Match only requests that include the given headers.
*/

@@ -71,3 +69,3 @@ withHeaders(headers) {

/**
* Match only requests that include the given query parameters
* Match only requests that include the given query parameters.
*/

@@ -79,4 +77,12 @@ withQuery(query) {

/**
* Match only requests whose bodies include the given form data
* Match only requests that include the exact query string provided.
* The query string must start with a ? or be entirely empty.
*/
withExactQuery(query) {
this.matchers.push(new matchers_1.ExactQueryMatcher(query));
return this;
}
/**
* Match only requests whose bodies include the given form data.
*/
withForm(formData) {

@@ -163,19 +169,17 @@ this.matchers.push(new matchers_1.FormDataMatcher(formData));

}
/**
* Reply to matched with with given status and (optionally) body
* and headers.
*
* Calling this method registers the rule with the server, so it
* starts to handle requests.
*
* This method returns a promise that resolves with a mocked endpoint.
* Wait for the promise to confirm that the rule has taken effect
* before sending requests to be matched. The mocked endpoint
* can be used to assert on the requests matched by this rule.
*/
thenReply(status, data, headers) {
thenReply(status, dataOrMessage, dataOrHeaders, headers) {
let data;
let statusMessage;
if (lodash_1.isBuffer(dataOrHeaders) || lodash_1.isString(dataOrHeaders)) {
data = dataOrHeaders;
statusMessage = dataOrMessage;
}
else {
data = dataOrMessage;
headers = dataOrHeaders;
}
const rule = {
matchers: this.matchers,
completionChecker: this.completionChecker,
handler: new handlers_1.SimpleHandler(status, data, headers)
handler: new handlers_1.SimpleHandler(status, statusMessage, data, headers)
};

@@ -205,3 +209,3 @@ return this.addRule(rule);

completionChecker: this.completionChecker,
handler: new handlers_1.SimpleHandler(status, JSON.stringify(data), defaultHeaders)
handler: new handlers_1.SimpleHandler(status, undefined, JSON.stringify(data), defaultHeaders)
};

@@ -324,2 +328,7 @@ return this.addRule(rule);

*
* The url may optionally contain a protocol. If it does, it will override
* the protocol (and potentially the port, if unspecified) of the request.
* If no protocol is specified, the protocol (and potentially the port)
* of the original request URL will be used instead.
*
* This method also takes options to configure how the request is passed

@@ -340,9 +349,2 @@ * through. The only option currently supported is ignoreHostCertificateErrors,

return __awaiter(this, void 0, void 0, function* () {
const { protocol, hostname, port, path } = url.parse(forwardToLocation);
if (path && path.trim() !== "/") {
const suggestion = url.format({ protocol, hostname, port });
throw new Error(common_tags_1.stripIndent `
URLs passed to thenForwardTo cannot include a path, but "${forwardToLocation}" does. ${''}Did you mean ${suggestion}?
`);
}
const rule = {

@@ -349,0 +351,0 @@ matchers: this.matchers,

@@ -20,2 +20,3 @@ /**

export interface MockRuleData {
id?: string;
matchers: matchers.RequestMatcher[];

@@ -22,0 +23,0 @@ handler: handlers.RequestHandler;

@@ -16,3 +16,3 @@ "use strict";

const serialization_1 = require("../util/serialization");
const request_utils_1 = require("../server/request-utils");
const request_utils_1 = require("../util/request-utils");
const matchers = require("./matchers");

@@ -32,2 +32,3 @@ const handlers = require("./handlers");

return {
id: data.id,
matchers: data.matchers.map(m => serialization_1.serialize(m, stream)),

@@ -42,2 +43,3 @@ handler: serialization_1.serialize(data.handler, stream),

return {
id: data.id,
matchers: data.matchers.map((m) => serialization_1.deserialize(m, stream, matchers.MatcherLookup)),

@@ -51,6 +53,6 @@ handler: serialization_1.deserialize(data.handler, stream, handlers.HandlerLookup),

constructor(data) {
this.id = uuid();
this.requests = [];
this.requestCount = 0;
validateMockRuleData(data);
this.id = data.id || uuid();
this.matchers = data.matchers;

@@ -57,0 +59,0 @@ this.handler = data.handler;

/**
* @module Mockttp
*/
import { CompletedRequest, CompletedResponse, TlsRequest } from "../types";
import { CompletedRequest, CompletedResponse, TlsRequest, InitiatedRequest } from "../types";
import { Mockttp, AbstractMockttp, MockttpOptions, PortRange } from "../mockttp";

@@ -32,7 +32,9 @@ import { MockRuleData } from "../rules/mock-rule";

addRules: (...ruleData: MockRuleData[]) => Promise<MockedEndpoint[]>;
on(event: 'request-initiated', callback: (req: InitiatedRequest) => void): Promise<void>;
on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;
on(event: 'response', callback: (req: CompletedResponse) => void): Promise<void>;
on(event: 'abort', callback: (req: CompletedRequest) => void): Promise<void>;
on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;
on(event: 'tlsClientError', callback: (req: TlsRequest) => void): Promise<void>;
private announceRequestAsync;
private announceInitialRequestAsync;
private announceCompletedRequestAsync;
private announceResponseAsync;

@@ -39,0 +41,0 @@ private announceAbortAsync;

@@ -27,4 +27,3 @@ "use strict";

const promise_1 = require("../util/promise");
const normalize_url_1 = require("../util/normalize-url");
const request_utils_1 = require("./request-utils");
const request_utils_1 = require("../util/request-utils");
const websocket_handler_1 = require("./websocket-handler");

@@ -79,6 +78,6 @@ /**

this.app.use((req, res, next) => {
// Relative URLs might be direct requests, or they might be transparently proxied
// (i.e. forcefully sent to us, they don't know a proxy is involved). If they appear
// to be the latter, we transform the URL into the absolute form.
if (request_utils_1.isIndirectPathRequest(this.port, req)) {
// 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.
// The 2nd argument is ignored if req.url is already absolute.
if (!request_utils_1.isAbsoluteUrl(req.url)) {
req.url = new url.URL(req.url, `${req.protocol}://${req.headers['host']}`).toString();

@@ -171,9 +170,13 @@ }

}
announceRequestAsync(request) {
announceInitialRequestAsync(request) {
setImmediate(() => {
const initiatedReq = request_utils_1.buildInitiatedRequest(request);
this.eventEmitter.emit('request-initiated', Object.assign(initiatedReq, { timingEvents: _.clone(initiatedReq.timingEvents) }));
});
}
announceCompletedRequestAsync(request) {
setImmediate(() => {
request_utils_1.waitForCompletedRequest(request)
.then((req) => {
this.eventEmitter.emit('request', Object.assign(req, {
timingEvents: _.clone(req.timingEvents)
}));
.then((completedReq) => {
this.eventEmitter.emit('request', Object.assign(completedReq, { timingEvents: _.clone(completedReq.timingEvents) }));
})

@@ -196,3 +199,3 @@ .catch(console.error);

return __awaiter(this, void 0, void 0, function* () {
const req = yield request_utils_1.waitForCompletedRequest(request);
const req = request_utils_1.buildAbortedRequest(request);
this.eventEmitter.emit('abort', Object.assign(req, {

@@ -222,7 +225,6 @@ timingEvents: _.clone(req.timingEvents)

id: id,
timingEvents,
normalizedUrl: normalize_url_1.default(rawRequest.url)
timingEvents
});
response.id = id;
this.announceRequestAsync(request);
this.announceInitialRequestAsync(request);
let result = null;

@@ -236,5 +238,14 @@ request.once('aborted', () => {

});
let nextRulePromise = promise_1.filter(this.rules, (r) => r.matches(request))
.then((matchingRules) => matchingRules.filter((r) => !this.isComplete(r, matchingRules))[0]);
// 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);
});
try {
let matchingRules = yield promise_1.filter(this.rules, (r) => r.matches(request));
let nextRule = matchingRules.filter((r) => !this.isComplete(r, matchingRules))[0];
let nextRule = yield nextRulePromise;
if (nextRule) {

@@ -241,0 +252,0 @@ if (this.debug)

@@ -18,2 +18,3 @@ "use strict";

const mock_rule_1 = require("../rules/mock-rule");
const REQUEST_INITIATED_TOPIC = 'request-initiated';
const REQUEST_RECEIVED_TOPIC = 'request-received';

@@ -126,2 +127,7 @@ const RESPONSE_COMPLETED_TOPIC = 'response-completed';

const pubsub = new graphql_subscriptions_1.PubSub();
mockServer.on('request-initiated', (request) => {
pubsub.publish(REQUEST_INITIATED_TOPIC, {
requestInitiated: request
});
});
mockServer.on('request', (request) => {

@@ -174,2 +180,5 @@ pubsub.publish(REQUEST_RECEIVED_TOPIC, {

}, Subscription: {
requestInitiated: {
subscribe: () => pubsub.asyncIterator(REQUEST_INITIATED_TOPIC)
},
requestReceived: {

@@ -176,0 +185,0 @@ subscribe: () => pubsub.asyncIterator(REQUEST_RECEIVED_TOPIC)

@@ -30,2 +30,4 @@ /**

export interface Request {
id: string;
matchedRuleId?: string;
protocol: string;

@@ -36,13 +38,12 @@ httpVersion?: string;

path: string;
hostname: string;
hostname?: string;
headers: RequestHeaders;
timingEvents: TimingEvents | {};
}
export declare type TlsRequest = {
export interface TlsRequest {
hostname?: string;
remoteIpAddress: string;
failureCause: 'closed' | 'reset' | 'cert-rejected' | 'no-shared-cipher' | 'unknown';
};
}
export interface OngoingRequest extends Request, EventEmitter {
id: string;
normalizedUrl: string;
body: ParsedBody;

@@ -69,6 +70,7 @@ timingEvents: TimingEvents;

}
export interface InitiatedRequest extends Request {
timingEvents: TimingEvents;
}
export interface CompletedRequest extends Request {
id: string;
body: CompletedBody;
timingEvents: TimingEvents | {};
}

@@ -75,0 +77,0 @@ export interface TimingEvents {

/**
* @module Internal
*/
export default function normalize(url: string): string;
import * as _ from 'lodash';
export declare const legacyNormalizeUrl: ((url: string) => string) & _.MemoizedFunction;
/**
* Normalizes URLs to the form used when matching them.
*
* This accepts URLs in all three formats: relative, absolute, and protocolless-absolute,
* and returns them in the same format but normalized.
*/
export declare const normalizeUrl: ((urlInput: string) => string) & _.MemoizedFunction;

@@ -6,11 +6,67 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
const normalizeUrl = require("normalize-url");
function normalize(url) {
return normalizeUrl(url, {
stripWWW: false,
removeTrailingSlash: false,
removeQueryParameters: [/.*/],
});
}
exports.default = normalize;
const url = require("url");
const _ = require("lodash");
const normalize = require("normalize-url");
const request_utils_1 = require("./request-utils");
// Preserved so we can correctly normalize serialized data, for backward compat
// with legacy servers.
exports.legacyNormalizeUrl = _.memoize((url) => normalize(url, {
stripWWW: false,
removeTrailingSlash: false,
removeQueryParameters: [/.*/],
}));
/**
* Normalizes URLs to the form used when matching them.
*
* This accepts URLs in all three formats: relative, absolute, and protocolless-absolute,
* and returns them in the same format but normalized.
*/
exports.normalizeUrl = _.memoize((urlInput) => {
let parsedUrl;
try {
// Strip the query and anything following it
const queryIndex = urlInput.indexOf('?');
if (queryIndex !== -1) {
urlInput = urlInput.slice(0, queryIndex);
}
if (request_utils_1.isAbsoluteProtocollessUrl(urlInput)) {
// Funky hack to let us parse URLs without any protocol.
// This is stripped off at the end of the function
parsedUrl = url.parse('protocolless://' + urlInput);
}
else {
parsedUrl = url.parse(urlInput);
}
// Trim out lots of the bits we don't like:
delete parsedUrl.host;
delete parsedUrl.query;
delete parsedUrl.search;
delete parsedUrl.hash;
if (parsedUrl.pathname) {
parsedUrl.pathname = parsedUrl.pathname.replace(/\%[A-Fa-z0-9]{2}/g, (encoded) => encoded.toUpperCase()).replace(/[^\u0000-\u007F]+/g, (unicodeChar) => encodeURIComponent(unicodeChar));
}
if (parsedUrl.hostname && parsedUrl.hostname.endsWith('.')) {
parsedUrl.hostname = parsedUrl.hostname.slice(0, -1);
}
if ((parsedUrl.protocol === 'https:' && parsedUrl.port === '443') ||
(parsedUrl.protocol === 'http:' && parsedUrl.port === '80')) {
delete parsedUrl.port;
}
}
catch (e) {
console.log(`Failed to normalize URL ${urlInput}`);
console.log(e);
if (!parsedUrl)
return urlInput; // Totally unparseble: use as-is
// If we've successfully parsed it, we format what we managed
// and leave it at that:
}
let normalizedUrl = url.format(parsedUrl);
// If the URL came in with no protocol, it should leave with
// no protocol (protocol added temporarily above to allow parsing)
if (normalizedUrl.startsWith('protocolless://')) {
normalizedUrl = normalizedUrl.slice('protocolless://'.length);
}
return normalizedUrl;
});
//# sourceMappingURL=normalize-url.js.map

@@ -16,3 +16,3 @@ /// <reference types="node" />

export declare type Serialized<T> = {
[K in keyof T]: T[K] extends Array<unknown> ? Array<SerializedValue<T[K][0]>> : SerializedValue<T[K]>;
[K in keyof T]: T[K] extends string | undefined ? string | undefined : T[K] extends Array<unknown> ? Array<SerializedValue<T[K][0]>> : SerializedValue<T[K]>;
};

@@ -19,0 +19,0 @@ export declare abstract class Serializable {

@@ -14,3 +14,3 @@ "use strict";

const uuid = require("uuid/v4");
const request_utils_1 = require("../server/request-utils");
const request_utils_1 = require("./request-utils");
function serialize(obj, stream) {

@@ -17,0 +17,0 @@ const channel = new ClientServerChannel(stream);

@@ -14,2 +14,3 @@ "use strict";

const net = require("net");
const util_1 = require("./util");
// Grab the first byte of a stream

@@ -57,11 +58,9 @@ // Note that this isn't a great abstraction: you might

exports.isLocalPortActive = isLocalPortActive;
// This file imported in browsers as it's used in handlers,
// but none of these methods are used. It is useful though to
// guard sections that perform immediate actions:
const isNode = typeof window === 'undefined';
exports.isLocalIPv6Available = isNode
// This file imported in browsers etc as it's used in handlers, but none of these methods are used
// directly. It is useful though to guard sections that immediately perform actions:
exports.isLocalIPv6Available = util_1.isNode
? _.some(os.networkInterfaces(), (addresses) => _.some(addresses, a => a.address === '::1'))
: true;
const LOCAL_ADDRESS_UPDATE_FREQ = 10 * 1000;
if (isNode) {
if (util_1.isNode) {
const updateLocalAddresses = () => {

@@ -68,0 +67,0 @@ exports.localAddresses = _(os.networkInterfaces())

{
"name": "mockttp",
"version": "0.16.1",
"version": "0.17.0",
"description": "Mock HTTP server for testing HTTP clients and stubbing webservices",

@@ -54,19 +54,17 @@ "main": "dist/main.js",

"devDependencies": {
"@types/base64-arraybuffer": "^0.1.0",
"@types/base64-arraybuffer": "0.1.0",
"@types/body-parser": "0.0.33",
"@types/chai": "^3.4.34",
"@types/chai-as-promised": "0.0.29",
"@types/common-tags": "^1.4.0",
"@types/graphql": "^14.0.2",
"@types/lodash": "^4.14.37",
"@types/mocha": "^2.2.32",
"@types/normalize-url": "^1.9.1",
"@types/request": "0.0.31",
"@types/semver": "^5.5.0",
"@types/chai": "3.5.2",
"@types/chai-as-promised": "7.1.0",
"@types/common-tags": "1.8.0",
"@types/graphql": "14.2.3",
"@types/lodash": "4.14.136",
"@types/mocha": "2.2.48",
"@types/normalize-url": "1.9.1",
"@types/request": "2.48.2",
"@types/semver": "5.5.0",
"@types/shelljs": "0.8.0",
"@types/sinon": "^1.16.31",
"@types/sinon-chai": "^2.7.27",
"@types/source-map-support": "^0.4.0",
"@types/uuid": "^3.4.0",
"@types/ws": "^5.1.2",
"@types/source-map-support": "0.4.2",
"@types/uuid": "3.4.5",
"@types/ws": "5.1.2",
"agent-base": "^4.2.1",

@@ -91,4 +89,2 @@ "catch-uncommitted": "^1.0.0",

"semver": "^5.5.0",
"sinon": "^1.17.5",
"sinon-chai": "^2.8.0",
"source-map-support": "^0.5.3",

@@ -121,4 +117,4 @@ "tmp-promise": "^1.0.3",

"graphql": "^14.0.2",
"graphql-subscriptions": "^0.5.5",
"graphql-tools": "~2.18.0",
"graphql-subscriptions": "^1.1.0",
"graphql-tools": "^4.0.5",
"httpolyglot": "^0.1.2",

@@ -125,0 +121,0 @@ "lodash": "^4.16.4",

@@ -106,3 +106,3 @@ # Mockttp [![Travis Build Status](https://img.shields.io/travis/httptoolkit/mockttp.svg)](https://travis-ci.org/httptoolkit/mockttp) [![Available on NPM](https://img.shields.io/npm/v/mockttp.svg)](https://npmjs.com/package/mockttp) [![Try Mockttp on RunKit](https://badge.runkitcdn.com/mockttp.svg)](https://npm.runkit.com/mockttp)

expect(requests.length).to.equal(1);
expect(requests[0].url).to.equal("/mocked-endpoint");
expect(requests[0].url).to.equal(`http://localhost:${mockServer.port}/mocked-endpoint`);
});

@@ -109,0 +109,0 @@

@@ -26,3 +26,3 @@ /**

import { MockedEndpointClient } from "./mocked-endpoint-client";
import { buildBodyReader } from '../server/request-utils';
import { buildBodyReader } from '../util/request-utils';
import { RequireProps } from '../util/type-utils';

@@ -54,5 +54,6 @@ import { introspectionQuery } from './introspection-query';

type SubscribableEvent = 'request' | 'response' | 'abort' | 'tlsClientError';
type SubscribableEvent = 'request-initiated' | 'request' | 'response' | 'abort' | 'tlsClientError';
const SUBSCRIBABLE_EVENTS: SubscribableEvent[] = [
'request-initiated',
'request',

@@ -224,2 +225,8 @@ 'response',

private typeHasInputField(typeName: string, fieldName: string): boolean {
const type: any = _.find(this.mockServerSchema.types, { name: typeName });
if (!type) return false;
return !!_.find(type.inputFields, { name: fieldName });
}
enableDebug(): void {

@@ -258,2 +265,17 @@ throw new Error("Client-side debug info not implemented.");

private _addRules = async (rules: MockRuleData[], reset: boolean = false): Promise<MockedEndpoint[]> => {
// Backward compat: make Add/SetRules work with servers that only define reset & addRule (singular).
// Adds a small risk of odd behaviour in the gap between reset & all the rules being added, but it
// should be extremely brief, and no worse than existing behaviour for those server versions.
if (!this.typeHasField('Mutation', 'addRules')) {
if (reset) await this.reset();
// Sequentially add the rules:
return rules.reduce((acc: Promise<MockedEndpoint[]>, rule) => {
return acc.then(async (endpoints) => {
endpoints.push(await this._addRule(rule));
return endpoints;
});
}, Promise.resolve<MockedEndpoint[]>([]));
}
const requestName = reset ? 'SetRules' : 'AddRules';

@@ -268,5 +290,9 @@ const mutationName = reset ? 'setRules' : 'addRules';

}`, {
newRules: rules.map((rule) =>
serializeRuleData(rule, this.mockServerStream!)
)
newRules: rules.map((rule) => {
const serializedData = serializeRuleData(rule, this.mockServerStream!)
if (!this.typeHasInputField('MockRule', 'id')) {
delete serializedData.id;
}
return serializedData;
})
}

@@ -280,16 +306,26 @@ )).rules.map(r => r.id);

public on(event: SubscribableEvent, callback: (data: any) => void): Promise<void> {
// Ignore unknown events
if (!_.includes(SUBSCRIBABLE_EVENTS, event)) return Promise.resolve();
// Ignore TLS error events, if not supported by the server
if (
event === 'tlsClientError' &&
!this.typeHasField('Subscription', 'failedTlsRequest')
) return Promise.resolve();
// Exists purely for backward compat with servers that don't support AddRules/SetRules.
private _addRule = async (rule: MockRuleData): Promise<MockedEndpoint> => {
const ruleData = serializeRuleData(rule, this.mockServerStream!)
delete ruleData.id; // Old servers don't support sending ids.
const standaloneStreamServer = this.mockServerOptions.standaloneServerUrl.replace(/^http/, 'ws');
const url = `${standaloneStreamServer}/server/${this.port}/subscription`;
const client = new SubscriptionClient(url, { }, WebSocket);
const response = await this.queryMockServer<{
addRule: { id: string }
}>(
`mutation AddRule($newRule: MockRule!) {
addRule(input: $newRule) {
id
}
}`, {
newRule: ruleData
}
);
const ruleId = response.addRule.id;
return new MockedEndpointClient(ruleId, this.getEndpointData(ruleId));
}
public on(event: SubscribableEvent, callback: (data: any) => void): Promise<void> {
const queryResultName = {
'request-initiated': 'requestInitiated',
request: 'requestReceived',

@@ -301,2 +337,12 @@ response: 'responseCompleted',

// Ignore events unknown to either us or the server
if (
!queryResultName ||
!this.typeHasField('Subscription', queryResultName)
) return Promise.resolve();
const standaloneStreamServer = this.mockServerOptions.standaloneServerUrl.replace(/^http/, 'ws');
const url = `${standaloneStreamServer}/server/${this.port}/subscription`;
const client = new SubscriptionClient(url, { }, WebSocket);
// Note the typeHasField checks - these are a quick hack for backward compatibility,

@@ -306,2 +352,19 @@ // introspecting the server schema to avoid requesting fields that don't exist on old servers.

const query = {
'request-initiated': {
operationName: 'OnRequestInitiated',
query: `subscription OnRequestInitiated {
${queryResultName} {
id,
protocol,
method,
url,
path,
hostname,
headers,
timingEvents,
httpVersion
}
}`
},
request: {

@@ -312,2 +375,3 @@ operationName: 'OnRequest',

id,
${this.typeHasField('Request', 'matchedRuleId') ? 'matchedRuleId' : ''}
protocol,

@@ -376,2 +440,3 @@ method,

}
if (data.timingEvents) {

@@ -382,2 +447,3 @@ data.timingEvents = JSON.parse(data.timingEvents);

}
if (data.body) {

@@ -384,0 +450,0 @@ data.body = buildBodyReader(Buffer.from(data.body, 'base64'), data.headers);

@@ -12,3 +12,3 @@ /**

// Export the core type definitions:
export { Method, OngoingRequest, CompletedRequest, CompletedResponse, MockedEndpoint } from "./types";
export { Method, InitiatedRequest, CompletedRequest, CompletedResponse, MockedEndpoint } from "./types";
export { Mockttp };

@@ -15,0 +15,0 @@

@@ -7,3 +7,11 @@ /**

import MockRuleBuilder from "./rules/mock-rule-builder";
import { ProxyConfig, MockedEndpoint, Method, CompletedRequest, CompletedResponse, TlsRequest } from "./types";
import {
ProxyConfig,
MockedEndpoint,
Method,
CompletedRequest,
CompletedResponse,
TlsRequest,
InitiatedRequest
} from "./types";
import { MockRuleData } from "./rules/mock-rule";

@@ -88,4 +96,16 @@ import { CAOptions } from './util/tls';

* Get a builder for a mock rule that will match GET requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -95,4 +115,16 @@ get(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match POST requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -102,4 +134,16 @@ post(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match PUT requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -109,4 +153,16 @@ put(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match DELETE requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -116,4 +172,16 @@ delete(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match PATCH requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -123,4 +191,16 @@ patch(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match HEAD requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*/

@@ -130,11 +210,23 @@ head(url: string | RegExp): MockRuleBuilder;

* Get a builder for a mock rule that will match OPTIONS requests for the given path.
*
*
* The path can be either a string, or a regular expression to match against.
*
* Path matching always ignores query parameters. To match query parameters,
* use .withQuery({ a: 'b' }) or withExactQuery('?a=b').
*
* There are a few supported matching formats:
* - Relative string paths (`/abc`) will be compared only to the request's path,
* independent of the host & protocol, ignoring query params.
* - Absolute string paths with no protocol (`localhost:8000/abc`) will be
* compared to the URL independent of the protocol, ignoring query params.
* - Fully absolute string paths (`http://localhost:8000/abc`) will be compared
* to entire URL, ignoring query params.
* - Regular expressions can match the absolute URL: `/^http:\/\/localhost:8000\/abc$/`
* - Regular expressions can also match the path: `/^\/abc/`
*
* This can only be used if the `cors` option has been set to false.
*
*
* If cors is true (the default when using a remote client, e.g. in the browser),
* then the mock server automatically handles OPTIONS requests to ensure requests
* to the server are allowed by clients observing CORS rules.
*
*
* You can pass `{cors: false}` to `getLocal`/`getRemote` to disable this behaviour,

@@ -147,3 +239,4 @@ * but if you're testing in a browser you will need to ensure you mock all OPTIONS

/**
* Subscribe to hear about request details as they're received.
* Subscribe to hear about request details as soon as the initial request details
* (method, path & headers) are received, without waiting for the body.
*

@@ -157,2 +250,14 @@ * This is only useful in some niche use cases, such as logging all requests seen

*/
on(event: 'request-initiated', callback: (req: InitiatedRequest) => void): Promise<void>;
/**
* Subscribe to hear about request details once the request is fully received.
*
* This is only useful in some niche use cases, such as logging all requests seen
* by the server independently of the rules defined.
*
* The callback will be called asynchronously from request handling. This function
* returns a promise, and the callback is not guaranteed to be registered until
* the promise is resolved.
*/
on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;

@@ -173,3 +278,4 @@

/**
* Subscribe to hear about requests that are aborted before the response is completed.
* Subscribe to hear about requests that are aborted before the request or
* response is fully completed.
*

@@ -183,3 +289,3 @@ * This is only useful in some niche use cases, such as logging all requests seen

*/
on(event: 'abort', callback: (req: CompletedRequest) => void): Promise<void>;
on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;

@@ -186,0 +292,0 @@ /**

@@ -21,3 +21,3 @@ /**

shouldKeepAlive
} from '../server/request-utils';
} from '../util/request-utils';
import { isLocalPortActive, localAddresses } from '../util/socket-util';

@@ -77,2 +77,3 @@ import {

public status: number,
public statusMessage?: string,
public data?: string | Buffer | SerializedBuffer,

@@ -86,2 +87,3 @@ public headers?: Headers

return `respond with status ${this.status}` +
(this.statusMessage ? ` (${this.statusMessage})`: "") +
(this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") +

@@ -95,3 +97,3 @@ (this.data ? ` and body "${this.data}"` : "");

}
response.writeHead(this.status);
response.writeHead(this.status, this.statusMessage);

@@ -455,7 +457,7 @@ if (isSerializedBuffer(this.data)) {

private forwardToLocation?: string;
private ignoreHostCertificateErrors: string[] = [];
public readonly forwardToLocation?: string;
public readonly ignoreHostCertificateErrors: string[] = [];
private beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult>;
private beforeResponse?: (res: PassThroughResponse) => MaybePromise<CallbackResponseResult>;
public readonly beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult>;
public readonly beforeResponse?: (res: PassThroughResponse) => MaybePromise<CallbackResponseResult>;

@@ -465,2 +467,14 @@ constructor(options: PassThroughHandlerOptions = {}, forwardToLocation?: string) {

// If a location is provided, and it's not a bare hostname, it must be parseable
if (forwardToLocation && forwardToLocation.includes('/')) {
const { protocol, hostname, port, path } = url.parse(forwardToLocation);
if (path && path.trim() !== "/") {
const suggestion = url.format({ protocol, hostname, port }) ||
forwardToLocation.slice(0, forwardToLocation.indexOf('/'));
throw new Error(stripIndent`
URLs passed to thenForwardTo cannot include a path, but "${forwardToLocation}" does. ${''
}Did you mean ${suggestion}?
`);
}
}
this.forwardToLocation = forwardToLocation;

@@ -485,14 +499,14 @@ this.ignoreHostCertificateErrors = options.ignoreHostCertificateErrors || [];

if (this.forwardToLocation) {
// Forward to location overrides the host only, not the path
({ protocol, hostname, port } = url.parse(this.forwardToLocation));
headers['host'] = `${hostname}:${port}`;
if (!this.forwardToLocation.includes('/')) {
// We're forwarding to a bare hostname
[hostname, port] = this.forwardToLocation.split(':');
} else {
// We're forwarding to a fully specified URL; override the host etc, but never the path.
({ protocol, hostname, port } = url.parse(this.forwardToLocation));
}
headers['host'] = hostname + (port ? `:${port}` : '');
}
// Check if this request is a request loop:
const socket: net.Socket = (<any> clientReq).socket;
// If it's ipv4 masquerading as v6, strip back to ipv4
const remoteAddress = socket.remoteAddress!.replace(/^::ffff:/, '');
const remotePort = port ? Number.parseInt(port) : socket.remotePort;
if (isRequestLoop(remoteAddress, remotePort!)) {
if (isRequestLoop((<any> clientReq).socket)) {
throw new Error(oneLine`

@@ -576,3 +590,2 @@ Passthrough loop detected. This probably means you're sending a request directly

let outgoingPort: null | number = null;
return new Promise<void>((resolve, reject) => {

@@ -598,10 +611,16 @@ let serverReq = makeRequest({

if (this.beforeResponse) {
const body = await streamToBuffer(serverRes);
const modifiedRes = await this.beforeResponse({
id: clientReq.id,
statusCode: serverStatusCode,
statusMessage: serverRes.statusMessage,
headers: serverHeaders,
body: buildBodyReader(body, serverHeaders)
});
let modifiedRes: CallbackResponseResult;
try {
const body = await streamToBuffer(serverRes);
modifiedRes = await this.beforeResponse({
id: clientReq.id,
statusCode: serverStatusCode,
statusMessage: serverRes.statusMessage,
headers: serverHeaders,
body: buildBodyReader(body, serverHeaders)
});
} catch (e) {
serverReq.abort();
return reject(e);
}

@@ -661,15 +680,12 @@ serverStatusCode = modifiedRes.statusCode ||

serverReq.once('socket', (socket: net.Socket) => {
// We want the local port - it's not available until we actually connect
socket.once('connect', () => {
// Add this port to our list of active ports
outgoingPort = socket.localPort;
currentlyForwardingPorts.push(outgoingPort);
});
socket.once('close', () => {
// Remove this port from our list of active ports
currentlyForwardingPorts = currentlyForwardingPorts.filter(
(port) => port !== outgoingPort
);
outgoingPort = null;
});
// This event can fire multiple times for keep-alive sockets, which are used to
// make multiple requests. If/when that happens, we don't need more event listeners.
if (currentlyForwardingSockets.has(socket)) return;
// Add this port to our list of active ports, once it's connected (before then it has no port)
socket.once('connect', () => currentlyForwardingSockets.add(socket));
// Remove this port from our list of active ports when it's closed
// This is called for both clean closes & errors.
socket.once('close', () => currentlyForwardingSockets.delete(socket));
});

@@ -721,2 +737,3 @@

forwardToLocation: this.forwardToLocation,
ignoreHostCertificateErrors: this.ignoreHostCertificateErrors,
hasBeforeRequestCallback: !!this.beforeRequest,

@@ -796,10 +813,24 @@ hasBeforeResponseCallback: !!this.beforeResponse

// Passthrough handlers need to spot loops - tracking ongoing request ports and the local machine's
// ip lets us get pretty close to doing that (for 1 step loops, at least):
// Passthrough handlers need to spot loops - tracking ongoing sockets lets us get pretty
// close to doing that (for 1 step loops, at least):
// Track currently live ports for forwarded connections, so we can spot requests from them later.
let currentlyForwardingPorts: Array<number> = [];
// We keep a list of all currently active outgoing sockets.
const currentlyForwardingSockets = new Set<net.Socket>();
const isRequestLoop = (remoteAddress: string, remotePort: number) =>
// If the request is local, and from a port we're sending a request on right now, we have a loop
_.includes(localAddresses, remoteAddress) && _.includes(currentlyForwardingPorts, remotePort)
// We need to normalize ips for comparison, because the same ip may be reported as ::ffff:127.0.0.1
// and 127.0.0.1 on the two sides of the connection, for the same ip.
const normalizeIp = (ip: string | undefined) =>
(ip && ip.startsWith('::ffff:'))
? ip.slice('::ffff:'.length)
: ip;
// For incoming requests, compare the address & port: if they match, we've almost certainly got a loop.
// I don't think it's generally possible to see the same ip on different interfaces from one process (you need
// ip-netns network namespaces), but if it is, then there's a tiny chance of false positives here. If we have ip X,
// and on another interface somebody else has ip X, and the send a request with the same incoming port as an
// outgoing request we have on the other interface, we'll assume it's a loop. Extremely unlikely imo.
const isRequestLoop = (incomingSocket: net.Socket) =>
_.some([...currentlyForwardingSockets], (outgoingSocket) =>
normalizeIp(outgoingSocket.localAddress) === normalizeIp(incomingSocket.remoteAddress) &&
outgoingSocket.remotePort === incomingSocket.localPort
);

@@ -7,8 +7,14 @@ /**

import * as url from 'url';
import { stripIndent } from 'common-tags';
import { oneLine } from 'common-tags';
import { OngoingRequest, Method, Explainable } from "../types";
import { Serializable } from "../util/serialization";
import normalizeUrl from "../util/normalize-url";
import {
isAbsoluteUrl,
getPathFromAbsoluteUrl,
isRelativeUrl,
getUrlWithoutProtocol
} from '../util/request-utils';
import { Serializable, ClientServerChannel } from "../util/serialization";
import { MaybePromise } from '../util/type-utils';
import { normalizeUrl, legacyNormalizeUrl } from '../util/normalize-url';

@@ -57,4 +63,2 @@ export interface RequestMatcher extends Explainable, Serializable {

private normalizedUrl: string;
constructor(

@@ -65,15 +69,32 @@ public path: string

if (!this.path) throw new Error('Invalid URL: URL to match must not be empty');
let { search, query } = url.parse(this.path, true);
if (search) {
throw new Error(stripIndent`
Tried to match a path that contained a query (${search}). ${''
}To match query parameters, add .withQuery(${JSON.stringify(query)}) instead.
throw new Error(oneLine`
Tried to match a path that contained a query (${search}).
To match query parameters, use .withQuery(${JSON.stringify(query)}) instead,
or .withExactQuery('${search}') to match this exact query string.
`);
}
this.normalizedUrl = normalizeUrl(this.path);
normalizeUrl(this.path); // Fail if URL can't be normalized
}
matches(request: OngoingRequest) {
return request.normalizedUrl === this.normalizedUrl;
const expectedUrl = normalizeUrl(this.path);
const reqUrl = normalizeUrl(request.url);
// reqUrl is always absolute, expectedUrl can be absolute, relative or protocolless-absolute
if (isRelativeUrl(expectedUrl)) {
// Match the path only, for any host
return getPathFromAbsoluteUrl(reqUrl) === expectedUrl;
} else if (isAbsoluteUrl(expectedUrl)) {
// Full absolute URL: match everything
return reqUrl === expectedUrl;
} else {
// Absolute URL with no protocol
return getUrlWithoutProtocol(reqUrl) === expectedUrl;
}
}

@@ -84,2 +105,9 @@

}
serialize(channel: ClientServerChannel) {
return Object.assign(super.serialize(channel), {
// For backward compat, will used by older (<0.17) servers
normalizedUrl: legacyNormalizeUrl(this.path)
});
}
}

@@ -89,17 +117,38 @@

readonly type = 'regex-path';
readonly regexString: string;
readonly regexSource: string;
constructor(regex: RegExp) {
super();
this.regexString = regex.source;
this.regexSource = regex.source;
}
matches(request: OngoingRequest) {
let urlMatcher = new RegExp(this.regexString);
return urlMatcher.test(request.normalizedUrl);
if (this.regexSource !== undefined) {
const absoluteUrl = normalizeUrl(request.url);
const urlPath = getPathFromAbsoluteUrl(absoluteUrl);
// Test the matcher against both the path alone & the full URL
const urlMatcher = new RegExp(this.regexSource);
return urlMatcher.test(absoluteUrl) ||
urlMatcher.test(urlPath);
} else {
const { regexString } = (this as this & { regexString: string });
// Old client, use old normalization & logic. Without this, old clients that check
// example.com$ will fail to match (they should check ...com/$)
let urlMatcher = new RegExp(regexString);
return urlMatcher.test(legacyNormalizeUrl(request.url));
}
}
explain() {
return `matching /${unescapeRegexp(this.regexString)}/`;
return `matching /${unescapeRegexp(this.regexSource)}/`;
}
serialize(channel: ClientServerChannel) {
return Object.assign(super.serialize(channel), {
// Backward compat for old servers
regexString: this.regexSource
});
}
}

@@ -126,2 +175,27 @@

export class ExactQueryMatcher extends Serializable implements RequestMatcher {
readonly type = 'exact-query-string';
constructor(
public query: string
) {
super();
if (query !== '' && query[0] !== '?') {
throw new Error('Exact query matches must start with ?, or be empty');
}
}
matches(request: OngoingRequest) {
const { search } = url.parse(request.url);
return this.query === search || (!search && !this.query);
}
explain() {
return this.query
? `with a query exactly matching \`${this.query}\``
: 'with no query string';
}
}
export class QueryMatcher extends Serializable implements RequestMatcher {

@@ -290,2 +364,3 @@ readonly type = 'query';

'query': QueryMatcher,
'exact-query-string': ExactQueryMatcher,
'form-data': FormDataMatcher,

@@ -292,0 +367,0 @@ 'raw-body': RawBodyMatcher,

@@ -7,3 +7,3 @@ /**

import { OutgoingHttpHeaders } from "http";
import { merge, isString } from "lodash";
import { merge, isString, isBuffer } from "lodash";
import { Readable } from "stream";

@@ -37,3 +37,4 @@ import { stripIndent } from "common-tags";

JsonBodyMatcher,
JsonBodyFlexibleMatcher
JsonBodyFlexibleMatcher,
ExactQueryMatcher
} from "./matchers";

@@ -110,3 +111,3 @@

/**
* Match only requests that include the given headers
* Match only requests that include the given headers.
*/

@@ -119,3 +120,3 @@ withHeaders(headers: { [key: string]: string }) {

/**
* Match only requests that include the given query parameters
* Match only requests that include the given query parameters.
*/

@@ -128,4 +129,13 @@ withQuery(query: { [key: string]: string | number | (string | number)[] }): MockRuleBuilder {

/**
* Match only requests whose bodies include the given form data
* Match only requests that include the exact query string provided.
* The query string must start with a ? or be entirely empty.
*/
withExactQuery(query: string): MockRuleBuilder {
this.matchers.push(new ExactQueryMatcher(query));
return this;
}
/**
* Match only requests whose bodies include the given form data.
*/
withForm(formData: { [key: string]: string }): MockRuleBuilder {

@@ -229,5 +239,9 @@ this.matchers.push(new FormDataMatcher(formData));

/**
* Reply to matched with with given status and (optionally) body
* and headers.
* Reply to matched with with given status code and (optionally) status message,
* body and headers.
*
* If one string argument is provided, it's used as the body. If two are
* provided (even if one is empty), then 1st is the status message, and
* the 2nd the body.
*
* Calling this method registers the rule with the server, so it

@@ -241,7 +255,24 @@ * starts to handle requests.

*/
thenReply(status: number, data?: string | Buffer, headers?: Headers): Promise<MockedEndpoint> {
thenReply(status: number, data?: string | Buffer, headers?: Headers): Promise<MockedEndpoint>;
thenReply(status: number, statusMessage: string, data: string | Buffer, headers?: Headers): Promise<MockedEndpoint>
thenReply(
status: number,
dataOrMessage?: string | Buffer,
dataOrHeaders?: string | Buffer | Headers,
headers?: Headers
): Promise<MockedEndpoint> {
let data: string | Buffer | undefined;
let statusMessage: string | undefined;
if (isBuffer(dataOrHeaders) || isString(dataOrHeaders)) {
data = dataOrHeaders as (Buffer | string);
statusMessage = dataOrMessage as string;
} else {
data = dataOrMessage as string | Buffer | undefined;
headers = dataOrHeaders as Headers | undefined;
}
const rule: MockRuleData = {
matchers: this.matchers,
completionChecker: this.completionChecker,
handler: new SimpleHandler(status, data, headers)
handler: new SimpleHandler(status, statusMessage, data, headers)
};

@@ -274,3 +305,3 @@

completionChecker: this.completionChecker,
handler: new SimpleHandler(status, JSON.stringify(data), defaultHeaders)
handler: new SimpleHandler(status, undefined, JSON.stringify(data), defaultHeaders)
};

@@ -409,2 +440,7 @@

*
* The url may optionally contain a protocol. If it does, it will override
* the protocol (and potentially the port, if unspecified) of the request.
* If no protocol is specified, the protocol (and potentially the port)
* of the original request URL will be used instead.
*
* This method also takes options to configure how the request is passed

@@ -424,11 +460,2 @@ * through. The only option currently supported is ignoreHostCertificateErrors,

async thenForwardTo(forwardToLocation: string, options?: PassThroughHandlerOptions): Promise<MockedEndpoint> {
const { protocol, hostname, port, path } = url.parse(forwardToLocation);
if (path && path.trim() !== "/") {
const suggestion = url.format({ protocol, hostname, port });
throw new Error(stripIndent`
URLs passed to thenForwardTo cannot include a path, but "${forwardToLocation}" does. ${''
}Did you mean ${suggestion}?
`);
}
const rule: MockRuleData = {

@@ -435,0 +462,0 @@ matchers: this.matchers,

@@ -11,3 +11,3 @@ /**

import { deserialize, serialize, Serialized } from '../util/serialization';
import { waitForCompletedRequest } from '../server/request-utils';
import { waitForCompletedRequest } from '../util/request-utils';
import { MaybePromise } from '../util/type-utils';

@@ -40,2 +40,3 @@

export interface MockRuleData {
id?: string;
matchers: matchers.RequestMatcher[];

@@ -50,6 +51,7 @@ handler: handlers.RequestHandler;

return {
id: data.id,
matchers: data.matchers.map(m => serialize(m, stream)),
handler: serialize(data.handler, stream),
completionChecker: data.completionChecker && serialize(data.completionChecker, stream)
}
};
};

@@ -59,2 +61,3 @@

return {
id: data.id,
matchers: data.matchers.map((m) =>

@@ -77,3 +80,3 @@ deserialize(m, stream, matchers.MatcherLookup)

public id: string = uuid();
public id: string;
public requests: Promise<CompletedRequest>[] = [];

@@ -85,2 +88,3 @@ public requestCount = 0;

this.id = data.id || uuid();
this.matchers = data.matchers;

@@ -87,0 +91,0 @@ this.handler = data.handler;

@@ -15,3 +15,3 @@ /**

import { OngoingRequest, CompletedRequest, CompletedResponse, OngoingResponse, TlsRequest } from "../types";
import { OngoingRequest, CompletedRequest, CompletedResponse, OngoingResponse, TlsRequest, InitiatedRequest } from "../types";
import { CAOptions } from '../util/tls';

@@ -24,11 +24,12 @@ import { DestroyableServer } from "../util/destroyable-server";

import { filter } from "../util/promise";
import normalizeUrl from "../util/normalize-url";
import {
isIndirectPathRequest,
parseBody,
waitForCompletedRequest,
trackResponse,
waitForCompletedResponse
} from "./request-utils";
waitForCompletedResponse,
isAbsoluteUrl,
buildInitiatedRequest,
buildAbortedRequest
} from "../util/request-utils";
import { WebSocketHandler } from "./websocket-handler";

@@ -74,6 +75,6 @@

this.app.use((req, res, next) => {
// Relative URLs might be direct requests, or they might be transparently proxied
// (i.e. forcefully sent to us, they don't know a proxy is involved). If they appear
// to be the latter, we transform the URL into the absolute form.
if (isIndirectPathRequest(this.port, req)) {
// 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.
// The 2nd argument is ignored if req.url is already absolute.
if (!isAbsoluteUrl(req.url)) {
req.url = new url.URL(req.url, `${req.protocol}://${req.headers['host']}`).toString();

@@ -183,5 +184,6 @@ }

public on(event: 'request-initiated', callback: (req: InitiatedRequest) => void): Promise<void>;
public on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;
public on(event: 'response', callback: (req: CompletedResponse) => void): Promise<void>;
public on(event: 'abort', callback: (req: CompletedRequest) => void): Promise<void>;
public on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;
public on(event: 'tlsClientError', callback: (req: TlsRequest) => void): Promise<void>;

@@ -193,9 +195,20 @@ public on(event: string, callback: (...args: any[]) => void): Promise<void> {

private announceRequestAsync(request: OngoingRequest) {
private announceInitialRequestAsync(request: OngoingRequest) {
setImmediate(() => {
const initiatedReq = buildInitiatedRequest(request);
this.eventEmitter.emit('request-initiated', Object.assign(
initiatedReq,
{ timingEvents: _.clone(initiatedReq.timingEvents) }
));
});
}
private announceCompletedRequestAsync(request: OngoingRequest) {
setImmediate(() => {
waitForCompletedRequest(request)
.then((req: CompletedRequest) => {
this.eventEmitter.emit('request', Object.assign(req, {
timingEvents: _.clone(req.timingEvents)
}));
.then((completedReq: CompletedRequest) => {
this.eventEmitter.emit('request', Object.assign(
completedReq,
{ timingEvents: _.clone(completedReq.timingEvents) }
));
})

@@ -219,3 +232,3 @@ .catch(console.error);

private async announceAbortAsync(request: OngoingRequest) {
const req = await waitForCompletedRequest(request);
const req = buildAbortedRequest(request);
this.eventEmitter.emit('abort', Object.assign(req, {

@@ -244,8 +257,7 @@ timingEvents: _.clone(req.timingEvents)

id: id,
timingEvents,
normalizedUrl: normalizeUrl(rawRequest.url)
timingEvents
});
response.id = id;
this.announceRequestAsync(request);
this.announceInitialRequestAsync(request);

@@ -261,6 +273,20 @@ let result: 'responded' | 'aborted' | null = null;

let nextRulePromise = filter(this.rules, (r) => r.matches(request))
.then((matchingRules) =>
matchingRules.filter((r) =>
!this.isComplete(r, matchingRules)
)[0] as MockRule | undefined
);
// 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);
});
try {
let matchingRules = await filter(this.rules, (r) => r.matches(request));
let nextRule = matchingRules.filter((r) => !this.isComplete(r, matchingRules))[0];
let nextRule = await nextRulePromise;
if (nextRule) {

@@ -267,0 +293,0 @@ if (this.debug) console.log(`Request matched rule: ${nextRule.explain()}`);

@@ -25,2 +25,3 @@ /**

const REQUEST_INITIATED_TOPIC = 'request-initiated';
const REQUEST_RECEIVED_TOPIC = 'request-received';

@@ -139,2 +140,8 @@ const RESPONSE_COMPLETED_TOPIC = 'response-completed';

mockServer.on('request-initiated', (request) => {
pubsub.publish(REQUEST_INITIATED_TOPIC, {
requestInitiated: request
})
});
mockServer.on('request', (request) => {

@@ -203,2 +210,5 @@ pubsub.publish(REQUEST_RECEIVED_TOPIC, {

Subscription: {
requestInitiated: {
subscribe: () => pubsub.asyncIterator(REQUEST_INITIATED_TOPIC)
},
requestReceived: {

@@ -205,0 +215,0 @@ subscribe: () => pubsub.asyncIterator(REQUEST_RECEIVED_TOPIC)

@@ -41,2 +41,5 @@ /**

export interface Request {
id: string;
matchedRuleId?: string;
protocol: string;

@@ -47,17 +50,21 @@ httpVersion?: string; // Like timingEvents - not set remotely with older servers

path: string;
hostname: string;
// Exists only if a host header is sent. A strong candidate for deprecation
// in future, since it's not clear that this comes from headers not the URL, and
// either way it duplicates existing data.
hostname?: string;
headers: RequestHeaders;
timingEvents: TimingEvents | {};
}
export type TlsRequest = {
export interface TlsRequest {
hostname?: string;
remoteIpAddress: string;
failureCause: 'closed' | 'reset' | 'cert-rejected' | 'no-shared-cipher' | 'unknown'
};
}
// Internal representation of an ongoing HTTP request whilst it's being processed
export interface OngoingRequest extends Request, EventEmitter {
id: string;
normalizedUrl: string;
body: ParsedBody;

@@ -83,6 +90,10 @@ timingEvents: TimingEvents;

// Internal & external representation of an initiated (no body yet received) HTTP request.
export interface InitiatedRequest extends Request {
timingEvents: TimingEvents;
}
// Internal & external representation of a fully completed HTTP request
export interface CompletedRequest extends Request {
id: string;
body: CompletedBody;
timingEvents: TimingEvents | {};
}

@@ -89,0 +100,0 @@

@@ -5,10 +5,92 @@ /**

import * as normalizeUrl from "normalize-url";
import * as url from 'url';
import * as _ from 'lodash';
import * as normalize from "normalize-url";
export default function normalize(url: string): string {
return normalizeUrl(url, {
stripWWW: false,
removeTrailingSlash: false,
removeQueryParameters: [/.*/],
});
}
import { nthIndexOf } from './util';
import { isAbsoluteUrl, isAbsoluteProtocollessUrl } from './request-utils';
// Preserved so we can correctly normalize serialized data, for backward compat
// with legacy servers.
export const legacyNormalizeUrl =
_.memoize(
(url: string): string =>
normalize(url, {
stripWWW: false,
removeTrailingSlash: false,
removeQueryParameters: [/.*/],
})
);
/**
* Normalizes URLs to the form used when matching them.
*
* This accepts URLs in all three formats: relative, absolute, and protocolless-absolute,
* and returns them in the same format but normalized.
*/
export const normalizeUrl =
_.memoize(
(urlInput: string): string => {
let parsedUrl: url.UrlWithStringQuery | undefined;
try {
// Strip the query and anything following it
const queryIndex = urlInput.indexOf('?');
if (queryIndex !== -1) {
urlInput = urlInput.slice(0, queryIndex);
}
if (isAbsoluteProtocollessUrl(urlInput)) {
// Funky hack to let us parse URLs without any protocol.
// This is stripped off at the end of the function
parsedUrl = url.parse('protocolless://' + urlInput);
} else {
parsedUrl = url.parse(urlInput);
}
// Trim out lots of the bits we don't like:
delete parsedUrl.host;
delete parsedUrl.query;
delete parsedUrl.search;
delete parsedUrl.hash;
if (parsedUrl.pathname) {
parsedUrl.pathname = parsedUrl.pathname.replace(
/\%[A-Fa-z0-9]{2}/g,
(encoded) => encoded.toUpperCase()
).replace(
/[^\u0000-\u007F]+/g,
(unicodeChar) => encodeURIComponent(unicodeChar)
);
}
if (parsedUrl.hostname && parsedUrl.hostname.endsWith('.')) {
parsedUrl.hostname = parsedUrl.hostname.slice(0, -1);
}
if (
(parsedUrl.protocol === 'https:' && parsedUrl.port === '443') ||
(parsedUrl.protocol === 'http:' && parsedUrl.port === '80')
) {
delete parsedUrl.port;
}
} catch (e) {
console.log(`Failed to normalize URL ${urlInput}`);
console.log(e);
if (!parsedUrl) return urlInput; // Totally unparseble: use as-is
// If we've successfully parsed it, we format what we managed
// and leave it at that:
}
let normalizedUrl = url.format(parsedUrl);
// If the URL came in with no protocol, it should leave with
// no protocol (protocol added temporarily above to allow parsing)
if (normalizedUrl.startsWith('protocolless://')) {
normalizedUrl = normalizedUrl.slice('protocolless://'.length);
}
return normalizedUrl;
}
);

@@ -7,3 +7,3 @@ import * as _ from 'lodash';

import { CompletedBody, Headers } from '../types';
import { buildBodyReader } from '../server/request-utils';
import { buildBodyReader } from './request-utils';

@@ -51,5 +51,7 @@ export function serialize<T extends Serializable>(

[K in keyof T]:
T[K] extends Array<unknown>
T[K] extends string | undefined
? string | undefined
: T[K] extends Array<unknown>
? Array<SerializedValue<T[K][0]>>
: SerializedValue<T[K]>;
: SerializedValue<T[K]>;
};

@@ -56,0 +58,0 @@

@@ -5,2 +5,4 @@ import * as _ from 'lodash';

import { isNode } from './util';
// Grab the first byte of a stream

@@ -44,7 +46,4 @@ // Note that this isn't a great abstraction: you might

// This file imported in browsers as it's used in handlers,
// but none of these methods are used. It is useful though to
// guard sections that perform immediate actions:
const isNode = typeof window === 'undefined';
// This file imported in browsers etc as it's used in handlers, but none of these methods are used
// directly. It is useful though to guard sections that immediately perform actions:
export const isLocalIPv6Available = isNode

@@ -51,0 +50,0 @@ ? _.some(os.networkInterfaces(),

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc