diff --git a/package.json b/package.json index fe277c2..987df7c 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,13 @@ }, "license": "MIT", "dependencies": { + "@types/http-proxy": "^1.16.2", "@types/lokijs": "^1.5.2", "@types/mkdirp": "^0.5.2", "content-type": "^1.0.4", "debug": "^4.1.1", "fast-json-stable-stringify": "^2.0.0", + "http-proxy": "^1.17.0", "incoming-message-hash": "^3.2.2", "invariant": "^2.2.4", "lodash.map": "^4.6.0", diff --git a/src/Recorder.ts b/src/Recorder.ts index 5254268..625a159 100644 --- a/src/Recorder.ts +++ b/src/Recorder.ts @@ -6,10 +6,11 @@ import { IncomingMessage, ServerResponse, IncomingHttpHeaders } from 'http' import { URL } from 'url' import { h64 } from 'xxhashjs' import { parse } from 'querystring' -import { buffer } from './buffer' -import { proxy } from './proxy' import * as curl from './curl' -import { isMatch } from 'lodash' +import { isMatch, pick } from 'lodash' +import httpProxy from 'http-proxy' +import { buffer } from './buffer' +import assert from 'assert' const debug = Debug('yaktime:recorder') @@ -18,6 +19,7 @@ type Unpacked = T extends (infer U)[] ? U : T extends (...args: any[]) => inf type SerializedRequest = ReturnType type SerializedResponse = Unpacked> interface FullSerializedRequest extends SerializedRequest { + $loki?: number response: SerializedResponse } @@ -25,19 +27,20 @@ export class Recorder { opts: YakTimeOpts host: string db: Promise + proxy: httpProxy constructor (opts: YakTimeOpts, host: string) { this.opts = opts this.host = host this.db = getDB(opts) + this.proxy = httpProxy.createProxyServer({ target: host, xfwd: true, changeOrigin: true, autoRewrite: true }) } - serializeRequest (req: IncomingMessage, body: any[]) { + serializeRequest (req: IncomingMessage, body: Buffer) { const fullUrl = new URL(req.url as string, this.host) - const { method = '', httpVersion, headers, trailers } = req + const { method, httpVersion, headers, trailers } = req - const bodyBuffer = Buffer.concat(body) - const bodyEncoded = bodyBuffer.toString('base64') - const bodyHash = h64(bodyBuffer, 0).toString(16) + const bodyEncoded = body.toString('base64') + const bodyHash = h64(body, 0).toString(16) return { host: fullUrl.host, @@ -52,36 +55,48 @@ export class Recorder { } } - async serializeResponse (res: IncomingMessage) { - const statusCode = res.statusCode || 200 + async serializeResponse (res: IncomingMessage, body: Buffer) { + const statusCode = res.statusCode as number const headers = res.headers - const body = Buffer.concat(await buffer(res)).toString('base64') const trailers = res.trailers return { statusCode, headers, - body, + body: body.toString('base64'), trailers } } - async respond (storedRes: SerializedResponse, res: ServerResponse) { + async respond (storedReq: FullSerializedRequest, res: ServerResponse) { + assert(res.headersSent === false, 'Response has already been delivered') + const storedRes = storedReq.response res.statusCode = storedRes.statusCode - res.writeHead(storedRes.statusCode, storedRes.headers) - res.addTrailers(storedRes.trailers || {}) - res.end(Buffer.from(storedRes.body, 'base64')) + if (storedReq.trailers != null && storedReq.trailers !== {}) { + res.addTrailers(storedReq.trailers) + } + res.writeHead(storedRes.statusCode, { 'x-yakbak-tape': storedReq.$loki, ...storedReq.response.headers }) + res.end(Buffer.from(storedReq.response.body, 'base64')) } - async record (req: IncomingMessage, body: Buffer[], host: string, opts: YakTimeOpts) { - ensureRecordingIsAllowed(req, opts) + async record (req: IncomingMessage, res: ServerResponse, body: Buffer): Promise { + ensureRecordingIsAllowed(req, this.opts) debug('proxy', req.url) - const pres = await proxy(req, body, host) - debug(curl.response(pres)) - ensureIsValidStatusCode(pres, opts) - debug('record', req.url) const request = this.serializeRequest(req, body) - const response = await this.serializeResponse(pres) + + const proxyRes: IncomingMessage = await new Promise((resolve, reject) => { + this.proxy.once('proxyRes', async (proxyRes: IncomingMessage) => { + resolve(proxyRes) + }) + this.proxy.once('error', reject) + this.proxy.web(req, res, { selfHandleResponse: true }) + }) + + debug(curl.response(proxyRes)) + debug('record', req.url) + ensureIsValidStatusCode(proxyRes, this.opts) + const proxiedResponseBody = await buffer(proxyRes) + const response = await this.serializeResponse(proxyRes, proxiedResponseBody) return this.save(request, response) } @@ -91,13 +106,13 @@ export class Recorder { return tapes.add({ ...request, response }) } - async read (req: IncomingMessage, body: Buffer[]) { + async read (req: IncomingMessage, body: Buffer) { const serializedRequest = this.serializeRequest(req, body) return this.load(serializedRequest) } async load (request: SerializedRequest): Promise { - const { ignoredQueryFields = [], ignoredHeaders = [] } = this.opts.hasherOptions || {} + const { ignoredQueryFields = [], allowedHeaders = [] } = this.opts const db = await this.db const tapes = db.addCollection('tapes', { disableMeta: true }) @@ -106,12 +121,9 @@ export class Recorder { const query = { ..._query } - const headers = { - ..._headers - } + const headers = pick(_headers, ['x-cassette-id', ...allowedHeaders]) ignoredQueryFields.forEach(q => delete query[q]) - ignoredHeaders.forEach(h => delete headers[h]) const lokiQuery = { ...request, @@ -119,36 +131,9 @@ export class Recorder { headers } - delete query.body + delete lokiQuery.body + delete lokiQuery.host return tapes.where(obj => isMatch(obj, lokiQuery))[0] } } - -export class DbMigrator { - data: Buffer[] = [] - headers: IncomingHttpHeaders = {} - statusCode = 200 - setHeader (name: string, value: string) { - this.headers[name] = value - } - write (input: Buffer | string) { - this.data.push(Buffer.isBuffer(input) ? input : Buffer.from(input)) - } - - end (data?: any) { - if (data != null) { - this.write(data) - } - debug('finished migration') - } - - toSerializedResponse (): SerializedResponse { - return { - statusCode: this.statusCode, - headers: this.headers, - body: Buffer.concat(this.data).toString('base64'), - trailers: {} - } - } -} diff --git a/src/buffer.test.ts b/src/buffer.test.ts index 608ce6f..bd33291 100644 --- a/src/buffer.test.ts +++ b/src/buffer.test.ts @@ -10,11 +10,11 @@ describe('buffer', () => { let str = new stream.PassThrough() subject(str) - .then(function(body) { - expect(body).toEqual([Buffer.from('a'), Buffer.from('b'), Buffer.from('c')]) + .then(function (body) { + expect(body).toEqual(Buffer.from('abc')) done() }) - .catch(function(err) { + .catch(function (err) { done(err) }) @@ -28,10 +28,10 @@ describe('buffer', () => { let str = new stream.PassThrough() subject(str) - .then(function() { + .then(function () { done(new Error('should have yielded an error')) }) - .catch(function(err) { + .catch(function (err) { expect(err.message).toEqual('boom') done() }) diff --git a/src/buffer.ts b/src/buffer.ts index 9387bba..fe513d5 100644 --- a/src/buffer.ts +++ b/src/buffer.ts @@ -9,20 +9,15 @@ import { Readable } from 'stream' * @param - stream */ -export function buffer (stream: Readable): Promise { - return new Promise(function (resolve, reject) { - let data: T[] = [] - - stream.on('data', function (buf) { - data.push(buf) - }) - - stream.on('error', function (err) { - reject(err) +export function buffer (stream: Readable): Promise { + return new Promise((resolve, reject) => { + let body = Buffer.from('') + stream.on('data', function (data) { + body = Buffer.concat([body, data]) }) - stream.on('end', function () { - resolve(data) + resolve(body) }) + stream.on('error', reject) }) } diff --git a/src/db.ts b/src/db.ts index 150c59b..c2296bf 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,7 +5,7 @@ import Debug from 'debug' const debug = Debug('yaktime:db') let db: Loki -export async function getDB (opts: YakTimeOpts) { +export async function getDB (opts: YakTimeOpts): Promise { if (db == null) { const dbPath = path.join(opts.dirname, 'tapes.json') debug(`Opening db on ${dbPath}`) diff --git a/src/proxy.test.ts b/src/proxy.test.ts deleted file mode 100644 index 133214f..0000000 --- a/src/proxy.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2016 Yahoo Inc. -// Copyright 2019 Ale Figueroa -// Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. - -import { proxy as subject } from './proxy' -import { createServer, TestServer } from './test/helpers/server' -import { IncomingMessage, ServerResponse } from 'http' -import { AddressInfo, Socket } from 'net' - -describe('proxy', () => { - let server: TestServer - let serverInfo: AddressInfo - let req: IncomingMessage - - beforeEach(async () => { - server = await createServer() - serverInfo = server.address() as AddressInfo - }) - - afterEach(async () => { - await server.closeAsync() - }) - - beforeEach(() => { - req = new IncomingMessage((null as unknown) as Socket) - req.method = 'GET' - req.url = '/' - req.headers['connection'] = 'close' - }) - - test('proxies the request', async () => { - expect.hasAssertions() - - const _preq = server.nextRequest() - await subject(req, [], `http://127.0.0.1:${serverInfo.port}`) - const preq = await _preq - expect(preq.method).toEqual(req.method) - expect(preq.url).toEqual(req.url) - expect(preq.headers.host).toEqual('127.0.0.1' + ':' + serverInfo.port) - }) - - it('overrides the host if one is set on the incoming request', async () => { - expect.hasAssertions() - req.headers['host'] = 'A.N.OTHER' - const preq = server.nextRequest() - await subject(req, [], `http://127.0.0.1:${serverInfo.port}`) - expect((await preq).headers.host).toEqual(`127.0.0.1:${serverInfo.port}`) - }) - - it('rewrites the location if there is a redirection', async () => { - expect.hasAssertions() - req.headers['host'] = `127.0.0.1:${serverInfo.port}` - server.prependOnceListener('request', (req: IncomingMessage, res: ServerResponse) => { - res.statusCode = 301 - res.setHeader('host', `localhost:${serverInfo.port}`) - res.setHeader('location', `http://localhost:${serverInfo.port}/potito`) - res.end() - }) - const res = await subject(req, [], `http://localhost:${serverInfo.port}`) - expect(res.headers['location']).toEqual(`http://127.0.0.1:${serverInfo.port}/potito`) - }) - - test('proxies the request body', done => { - let body = [Buffer.from('a'), Buffer.from('b'), Buffer.from('c')] - type onType = (buf?: any, cb?: any) => void - - server.once('request', function (_req: { on: onType }) { - let data: Buffer[] = [] - - _req.on('data', function (buf: Buffer) { - data.push(buf) - }) - - _req.on('end', function () { - expect(Buffer.concat(data)).toEqual(Buffer.concat(body)) - done() - }) - }) - - req.method = 'POST' - - subject(req, body, `http://127.0.0.1:${serverInfo.port}`).catch(function (err: any) { - done(err) - }) - }) - - test('yields the response', async () => { - const res = await subject(req, [], `http://127.0.0.1:${serverInfo.port}`) - expect(res.statusCode).toEqual(201) - }) -}) diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index ef1adf4..0000000 --- a/src/proxy.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2016 Yahoo Inc. -// Copyright 2019 Ale Figueroa -// Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. -import * as https from 'https' -import * as http from 'http' -import Debug from 'debug' -import * as url from 'url' -import invariant from 'invariant' - -const debug = Debug('yaktime:proxy') - -/** - * Protocol to module map, natch. - * @private - */ - -const mods = { 'http:': http, 'https:': https } - -export interface YakTimeIncomingMessage extends http.IncomingMessage { - req: http.ClientRequest -} - -/** - * Proxy `req` to `host` and yield the response. - * @param - req - * @param - body - * @param - host - */ - -export function proxy (res: http.IncomingMessage, body: Buffer[], host: string): Promise { - invariant(res.method != null, 'HTTP Method has to be defined') - debug(`${res.method} ${res.url}`) - return new Promise(function (resolve) { - const uri = url.parse(host) - const mod = mods[uri.protocol as keyof typeof mods] || http - const request = { - hostname: uri.hostname, - port: uri.port, - method: res.method, - path: res.url, - headers: { ...res.headers, host: uri.host }, - servername: uri.hostname, - rejectUnauthorized: false - } - const preq = mod.request(request, function (pres) { - const statusCode = pres.statusCode - if (statusCode != null && statusCode >= 300 && statusCode < 400) { - const location = pres.headers['location'] as string - debug('redirect', 'rewriting', uri.host, '=>', res.headers['host']) - pres.headers['location'] = location.replace(uri.host as string, res.headers['host'] as string) - } - resolve(pres as YakTimeIncomingMessage) - }) - - debug('req', res.url, 'host', uri.host) - - body.forEach(function (buf) { - preq.write(buf) - }) - - preq.end() - }) -} diff --git a/src/record.test.ts b/src/record.test.ts deleted file mode 100644 index 3fa49b6..0000000 --- a/src/record.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2016 Yahoo Inc. -// Copyright 2019 Ale Figueroa -// Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. - -import * as http from 'http' -import * as fs from 'fs' -import { record as subject } from './record' - -import fixture from './test/fixtures' -import { createServer, TestServer } from './test/helpers/server' -import { createTmpdir, Dir } from './test/helpers/tmpdir' -import { AddressInfo } from 'net' - -describe('record', () => { - let server: TestServer - let tmpdir: Dir - let req: http.ClientRequest - - beforeEach(async () => { - server = await createServer() - }) - - afterEach(async () => { - await server.closeAsync() - }) - - beforeEach(async () => { - tmpdir = await createTmpdir() - }) - - afterEach(async () => { - await tmpdir.teardown() - }) - - beforeEach(() => { - const info = server.address() as AddressInfo - req = http.request({ - host: 'localhost', - port: info.port, - headers: { - 'User-Agent': 'My User Agent/1.0', - Connection: 'close' - } - }) - }) - - test('returns the filename', done => { - expect.assertions(1) - req.on('response', async function(res: http.IncomingMessage) { - const filename = await subject(req, res, tmpdir.join('foo.js')) - expect(filename).toEqual(tmpdir.join('foo.js')) - done() - }) - - req.end() - }) - - test('records the response to disk', done => { - const info = server.address() as AddressInfo - let expected = fixture.replace('{addr}', 'localhost').replace('{port}', `${info.port}`) - - req.on('response', function(res: http.IncomingMessage) { - subject(req, res, tmpdir.join('foo.js')) - .then(function(filename) { - expect(fs.readFileSync(filename, 'utf8')).toEqual(expected) - done() - }) - .catch(function(err) { - done(err) - }) - }) - - req.end() - }) -}) diff --git a/src/record.ts b/src/record.ts deleted file mode 100644 index 749c526..0000000 --- a/src/record.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2016 Yahoo Inc. -// Copyright 2019 Ale Figueroa -// Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. - -import Debug from 'debug' -import { ClientRequest, IncomingMessage } from 'http' -import { buildTape } from './tape' -import { writeFileAsync, ensureIsModuleNotFoundError, ensureRecordingIsAllowed, ensureIsValidStatusCode, YakTimeOpts } from './util' -import { ModuleNotFoundError } from './errors' -import { proxy } from './proxy' -import * as curl from './curl' - -const debug = Debug('yaktime:legacy-record') - -/** - * Write `data` to `filename`. - * @param - filename - * @param - data - */ - -function write (filename: string, data: string) { - debug('write', filename) - return writeFileAsync(filename, data, 'utf8') -} - -/** - * Record the http interaction between `req` and `res` to disk. - * The format is a vanilla node module that can be used as - * an http.Server handler. - * @param - req - * @param - res - * @param - filename - */ - -export async function record (req: ClientRequest, res: IncomingMessage, filename: string) { - const tapeCode = await buildTape(req, res) - await write(filename, tapeCode) - return filename -} - -export const recordIfNotFound = (req: IncomingMessage, body: Buffer[], host: string, file: string, opts: YakTimeOpts) => - async function recordIfNotFound (e: ModuleNotFoundError) { - ensureIsModuleNotFoundError(e) - ensureRecordingIsAllowed(req, opts) - - debug('proxy', req.url) - const pres = await proxy(req, body, host) - debug(curl.response(pres)) - ensureIsValidStatusCode(pres, opts) - debug('record', req.url) - return record(pres.req, pres, file) - } diff --git a/src/requestHasher.test.ts b/src/requestHasher.test.ts deleted file mode 100644 index db054cf..0000000 --- a/src/requestHasher.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { HasherRequest, requestHasher } from './requestHasher' - -const defaultRequest = { - url: 'http://google.com/', - headers: { foo: 'bar' }, - httpVersion: '1.1', - method: 'GET', - trailers: { bar: 'foo' } -} - -describe('requestHasher', () => { - test('hash with same query is the same', () => { - const hasher = requestHasher() - const req1: HasherRequest = { - ...defaultRequest, - url: 'http://google.com/?foo=bar&asd=qwe' - } - const req2 = { ...defaultRequest, url: 'http://google.com/?asd=qwe&foo=bar' } - expect(hasher(req1)).toEqual(hasher(req2)) - }) - - test('hash with same headers is the same', () => { - const hasher = requestHasher() - const req1: HasherRequest = { - ...defaultRequest, - headers: { foo: 'bar', bar: 'foo' } - } - const req2 = { ...defaultRequest, headers: { foo: 'bar', bar: 'foo' } } - expect(hasher(req1)).toEqual(hasher(req2)) - }) - - test('hash with same trailers is the same', () => { - const hasher = requestHasher() - const req1: HasherRequest = { - ...defaultRequest, - trailers: { foo: 'bar', bar: 'foo' } - } - const req2 = { ...req1, trailers: { foo: 'bar', bar: 'foo' } } - expect(hasher(req1)).toEqual(hasher(req2)) - }) - - test('handles different method', () => { - const hasher = requestHasher() - const req1: HasherRequest = { - ...defaultRequest, - method: 'GET' - } - const req2 = { ...defaultRequest, method: 'POST' } - expect(hasher(req1)).not.toEqual(hasher(req2)) - }) - - test('handle different pathnames', () => { - const hasher = requestHasher() - const req1: HasherRequest = { - ...defaultRequest, - url: 'http://google.com/foo' - } - const req2 = { ...defaultRequest, url: 'http://google.com/bar' } - expect(hasher(req1)).not.toEqual(hasher(req2)) - }) - - test('handle different bodies', () => { - const hasher = requestHasher() - const body1 = Buffer.concat([Buffer.from('CHON'), Buffer.from('MAGUE')]) - const body2 = Buffer.from('POTATO') - expect(hasher(defaultRequest, body1)).not.toEqual(hasher(defaultRequest, body2)) - }) - - test('handle same body in different chunks', () => { - const hasher = requestHasher() - const body1 = Buffer.concat([Buffer.from('CHON'), Buffer.from('MAGUE')]) - const body2 = Buffer.from('CHONMAGUE') - expect(hasher(defaultRequest, body1)).toEqual(hasher(defaultRequest, body2)) - }) -}) diff --git a/src/requestHasher.ts b/src/requestHasher.ts deleted file mode 100644 index 73be37f..0000000 --- a/src/requestHasher.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { h64 } from 'xxhashjs' -import { IncomingMessage } from 'http' -import invariant from 'invariant' -import { parse } from 'querystring' -import stableStringifier from 'fast-json-stable-stringify' - -export interface RequestHasherOptions { - ignoredQueryFields?: string[] - ignoredHeaders?: string[] -} - -type HasheableFields = 'url' | 'headers' | 'httpVersion' | 'method' | 'trailers' -export type HasherRequest = Pick - -export const requestHasher = ({ ignoredQueryFields = [], ignoredHeaders = [] }: RequestHasherOptions = {}) => - function requestHasher (req: HasherRequest, body: Buffer = Buffer.from('')) { - invariant(req.url != null, 'URL is not valid') - const { method = '', httpVersion, headers, trailers = {} } = req - const h = h64() - const url = req.url as string - - const { searchParams, pathname } = new URL(url, 'http://localhost') - ignoredQueryFields.forEach(q => searchParams.delete(q)) - - let query = parse(searchParams.toString()) - const newHeaders = { ...headers } - - ignoredHeaders.forEach(h => delete headers[h]) - - h.update(httpVersion) - h.update(method) - h.update(pathname) - h.update(stableStringifier(query)) - h.update(stableStringifier(newHeaders)) - h.update(stableStringifier(trailers)) - h.update(body) - - return h.digest().toString(16) - } diff --git a/src/tape.test.ts b/src/tape.test.ts deleted file mode 100644 index a8ae697..0000000 --- a/src/tape.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { ClientRequest, IncomingMessage } from 'http' -import { buildTape } from './tape' -import { EventEmitter } from 'events' - -const linter = require('standard') - -// We will have quotes error on the standard -const isStandardJs = (js: string) => linter.lintTextSync(js, {}).results.every(r => r.messages[0].ruleId === 'quotes') - -const req: unknown = { - method: 'GET', - path: '/about?foo=bar', - getHeaders: () => ({ - 'user-agent': - 'Mozilla/5.0 (Windows NT 10.0; ARM; Lumia 950 Dual SIM) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393', - accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,* / *;q=0.8', - 'accept-language': 'en-US;q=0.8,en;q=0.5', - 'cache-control': 'false', - host: 'www.google.com', - 'accept-encoding': 'gzip, deflate', - connection: 'close' - }) -} - -const getRes = (body: EventEmitter, headers: any) => - Object.assign(body, { - headers, - statusCode: 200 - }) - -describe('buildTape', () => { - it('records non-readable content', async () => { - const body = new EventEmitter() - - const res: unknown = getRes(body, { - 'content-type': 'image/gif', - 'content-encoding': 'gzip', - foo: 'bar\'bar"bar`bar' - }) - - const tape = buildTape(req as ClientRequest, res as IncomingMessage) - body.emit('data', Buffer.from('CHON')) - body.emit('data', Buffer.from('MAGUE')) - body.emit('end') - expect(await tape).toEqual(`const { basename } = require('path') - -/** - * GET /about?foo=bar - * - * user-agent: Mozilla/5.0 (Windows NT 10.0; ARM; Lumia 950 Dual SIM) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393 - * accept: text/html,application/xhtml+xml,application/xml;q=0.9,* / *;q=0.8 - * accept-language: en-US;q=0.8,en;q=0.5 - * cache-control: false - * host: www.google.com - * accept-encoding: gzip, deflate - * connection: close - */ - -module.exports = function (req, res) { - res.statusCode = 200 - - res.setHeader('content-type', "image/gif") - res.setHeader('content-encoding', "gzip") - res.setHeader('foo', "bar'bar\\"bar\`bar") - - res.setHeader('x-yakbak-tape', basename(__filename, '.js')) - - res.write(Buffer.from("Q0hPTg==", 'base64')) - res.write(Buffer.from("TUFHVUU=", 'base64')) - res.end() - - return __filename -} -`) - expect(isStandardJs(await tape)).toEqual(true) - }) - - it('record readable content', async () => { - const body = new EventEmitter() - - const res: unknown = getRes(body, { - 'content-type': 'text/html', - 'content-encoding': 'identity', - foo: 'bar\'bar"bar`bar' - }) - - const tape = buildTape(req as ClientRequest, res as IncomingMessage) - body.emit('data', Buffer.from('YAK')) - body.emit('data', Buffer.from('\'"`TIME')) - body.emit('end') - - expect(await tape).toEqual(`const { basename } = require('path') - -/** - * GET /about?foo=bar - * - * user-agent: Mozilla/5.0 (Windows NT 10.0; ARM; Lumia 950 Dual SIM) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393 - * accept: text/html,application/xhtml+xml,application/xml;q=0.9,* / *;q=0.8 - * accept-language: en-US;q=0.8,en;q=0.5 - * cache-control: false - * host: www.google.com - * accept-encoding: gzip, deflate - * connection: close - */ - -module.exports = function (req, res) { - res.statusCode = 200 - - res.setHeader('content-type', "text/html") - res.setHeader('content-encoding', "identity") - res.setHeader('foo', "bar'bar\\"bar\`bar") - - res.setHeader('x-yakbak-tape', basename(__filename, '.js')) - - res.write(Buffer.from("YAK", 'utf8')) - res.write(Buffer.from("'\\"\`TIME", 'utf8')) - res.end() - - return __filename -} -`) - expect(isStandardJs(await tape)).toEqual(true) - }) -}) diff --git a/src/tape.ts b/src/tape.ts deleted file mode 100644 index 96ee08a..0000000 --- a/src/tape.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { IncomingMessage, ClientRequest } from 'http' -import map from 'lodash.map' -import { buffer } from './buffer' -import { ParsedMediaType, parse as contentTypeParse } from 'content-type' - -const humanReadableContentTypes = ['application/javascript', 'application/json', 'text/css', 'text/html', 'text/javascript', 'text/plain'] - -/** - * Returns whether a content-type is human readable based on a whitelist - * @param - contentType - */ -function isContentTypeHumanReadable (contentType: ParsedMediaType) { - return humanReadableContentTypes.indexOf(contentType.type) >= 0 -} - -/** - * Returns whether a request's body is human readable - * @param - req - */ -function isHumanReadable (res: IncomingMessage) { - const contentEncoding = res.headers['content-encoding'] - const contentType = res.headers['content-type'] - const identityEncoding = !contentEncoding || contentEncoding === 'identity' - - if (contentType == null) return - const parsedContentType = contentTypeParse(contentType) - - return identityEncoding && isContentTypeHumanReadable(parsedContentType) -} - -function escapeComments (str: string) { - return str - .split('/*') - .join('/ *') - .split('*/') - .join('* /') -} - -function joinIndented (strings: string[], { indent = ' ', skipFirst = true } = {}) { - return strings - .map(str => `${indent}${str}`) - .join('\n') - .replace(skipFirst ? indent : '', '') -} - -/** - * Record the http interaction between `req` and `res` to an string. - * The format is a vanilla node module that can be used as - * an http.Server handler. - * @param - req - * @param - res - */ -export async function buildTape (req: ClientRequest, res: IncomingMessage) { - const body = await buffer(res) - const encoding = isHumanReadable(res) ? 'utf8' : 'base64' - const requestText = `${(req as any).method} ${escapeComments(decodeURIComponent((req as any).path))}` - const requestHeaders = joinIndented(map(req.getHeaders(), (value, header) => `${header}: ${escapeComments((value || '').toString())}`), { - indent: ' * ' - }) - const responseHeaders = joinIndented(map(res.headers, (value, header) => `res.setHeader('${header}', ${JSON.stringify(value)})`)) - const bodyText = joinIndented(body.map(data => `res.write(Buffer.from(${JSON.stringify(data.toString(encoding))}, '${encoding}'))`)) - - return `const { basename } = require('path') - -/** - * ${requestText} - * - * ${requestHeaders} - */ - -module.exports = function (req, res) { - res.statusCode = ${res.statusCode} - - ${responseHeaders} - - res.setHeader('x-yakbak-tape', basename(__filename, '.js')) - - ${bodyText} - res.end() - - return __filename -} -` -} diff --git a/src/tapeMigrator.test.ts b/src/tapeMigrator.test.ts deleted file mode 100644 index 9665b2f..0000000 --- a/src/tapeMigrator.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2016 Yahoo Inc. -// Copyright 2019 Ale Figueroa -// Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. - -import * as http from 'http' -import * as fs from 'fs' -import * as path from 'path' - -import { createServer, TestServer } from './test/helpers/server' -import { createTmpdir, Dir } from './test/helpers/tmpdir' -import { AddressInfo } from 'net' -import { tapename, RequestHasher, YakTimeOpts } from './util' -import { requestHasher } from './requestHasher' -import { fileTapeMigrator, dbTapeMigrator } from './tapeMigrator' -import { yaktime } from './yaktime' -import { notifyNotUsedTapes } from './tracker' -import { Recorder } from './Recorder' - -const incMessH = require('incoming-message-hash') -const messageHash: RequestHasher = incMessH.sync - -describe('record', () => { - let server: TestServer - let proxyServer: TestServer - let tmpdir: Dir - let req: http.ClientRequest - let serverInfo: AddressInfo - let proxyServerInfo: AddressInfo - - beforeEach(async () => { - tmpdir = await createTmpdir() - server = await createServer() - serverInfo = server.address() as AddressInfo - const opts = { dirname: tmpdir.dirname, migrate: false } - const tape = yaktime(`http://localhost:${serverInfo.port}`, opts) - proxyServer = await createServer(false, tape) - proxyServer.once('close', () => notifyNotUsedTapes(opts, tape.hits)) - proxyServerInfo = proxyServer.address() as AddressInfo - }) - - afterEach(async () => { - await server.closeAsync() - await proxyServer.closeAsync() - await tmpdir.teardown() - }) - - beforeEach(() => { - req = http.request({ - host: 'localhost', - port: proxyServerInfo.port, - headers: { - 'User-Agent': 'My User Agent/1.0', - Connection: 'close' - } - }) - }) - - test('copies the file with the new name', done => { - expect.hasAssertions() - - req.once('response', async function () { - const serverReq = proxyServer.requests[0] - const newHash = requestHasher({}) - const oldFileName = path.join(tmpdir.dirname, tapename(messageHash, serverReq)) - const newFileName = path.join(tmpdir.dirname, tapename(newHash, serverReq)) - expect(fs.existsSync(oldFileName)).toEqual(true) - expect(fs.existsSync(newFileName)).toEqual(false) - await fileTapeMigrator(newHash, { dirname: tmpdir.dirname })(serverReq) - expect(fs.existsSync(oldFileName)).toEqual(true) - expect(fs.existsSync(newFileName)).toEqual(true) - done() - }) - - req.end() - }) - - test('add existing request to the db', done => { - expect.hasAssertions() - - req.once('response', async function () { - const migrator = new Recorder({ dirname: tmpdir.dirname, useDb: true } as YakTimeOpts, `http://localhost:${serverInfo.port}`) - const serverReq = proxyServer.requests[0] - const oldFileName = path.join(tmpdir.dirname, tapename(messageHash, serverReq)) - expect(fs.existsSync(oldFileName)).toEqual(true) - expect(await migrator.read(serverReq, [])).toBeUndefined() - await dbTapeMigrator(`http://localhost:${serverInfo.port}`, { dirname: tmpdir.dirname, useDb: true })(serverReq) - expect(fs.existsSync(oldFileName)).toEqual(true) - expect(await migrator.read(serverReq, [])).toEqual( - expect.objectContaining({ - method: 'GET', - path: '/', - response: expect.objectContaining({ - statusCode: 201, - body: 'T0s=' - }) - }) - ) - done() - }) - - req.end() - }) -}) diff --git a/src/tapeMigrator.ts b/src/tapeMigrator.ts deleted file mode 100644 index caea9a6..0000000 --- a/src/tapeMigrator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { RequestHasher, YakTimeOpts, tapename, copyFileAsync } from './util' -import * as path from 'path' -import Debug from 'debug' -import { IncomingMessage } from 'http' -import { existsSync } from 'fs' -import { requestHasher } from './requestHasher' -import { Recorder, DbMigrator } from './Recorder' - -const debug = Debug('yaktime:tape-migrator') - -const incMessH = require('incoming-message-hash') -const oldHasher: RequestHasher = incMessH.sync - -type tapeMigratorOptions = 'dirname' | 'oldHash' -export const fileTapeMigrator = (newHasher: RequestHasher, opts: Pick) => - async function tapeMigrator (req: IncomingMessage, body: Buffer[] = []) { - const oldFileName = path.join(opts.dirname, tapename(opts.oldHash || oldHasher, req, body)) - const newFileName = path.join(opts.dirname, tapename(newHasher, req, body)) - const oldExists = existsSync(oldFileName) - - if (oldExists) { - debug('migrating to file') - debug('old filename', oldFileName) - const newExists = existsSync(newFileName) - if (newExists) { - return debug('skipping migration', newFileName, 'already exists') - } - - debug('new filename', newFileName) - await copyFileAsync(oldFileName, newFileName) - debug('remove old file manually') - } - } - -export const dbTapeMigrator = (host: string, opts: YakTimeOpts) => - async function tapeMigrator (req: IncomingMessage, body: Buffer[] = []) { - const recorder = new Recorder(opts, host) - const oldFileName = path.join(opts.dirname, tapename(opts.oldHash || oldHasher, req, body)) - const oldExists = existsSync(oldFileName) - - if (oldExists) { - debug('migrating to db') - debug('filename', oldFileName) - - const migrator = new DbMigrator() - require(oldFileName)(null, migrator) - const request = recorder.serializeRequest(req, body) - const response = migrator.toSerializedResponse() - debug('saving to db') - await recorder.save(request, response) - } - } - -type migrateIfRequiredOptions = 'hash' | 'migrate' | 'hasherOptions' | 'useDb' -export async function migrateIfRequired ( - host: string, - opts: Pick, - req: IncomingMessage, - body: Buffer[] = [] -) { - if (opts.hash != null || opts.migrate === false) { - return - } - const newHasher = requestHasher(opts.hasherOptions) - await dbTapeMigrator(host, opts)(req, body) - await fileTapeMigrator(newHasher, opts)(req, body) -} diff --git a/src/test/helpers/server.ts b/src/test/helpers/server.ts index 70a6ddd..ebe4e26 100644 --- a/src/test/helpers/server.ts +++ b/src/test/helpers/server.ts @@ -23,9 +23,6 @@ export async function createServer (failRequest = false, handler?: YakTimeServer const requests: http.IncomingMessage[] = [] let defaultHandler: YakTimeServer = (req, res) => { - if (res.finished) { - return - } res.statusCode = failRequest === true ? 404 : 201 res.setHeader('Content-Type', 'text/html') res.setHeader('Date', 'Sat, 26 Oct 1985 08:20:00 GMT') diff --git a/src/util.ts b/src/util.ts index 5bb1f48..68cdcfd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,9 @@ import { promisify } from 'util' -import { readFile, writeFile, existsSync, copyFile, readdir } from 'fs' +import { readFile, writeFile, copyFile, readdir } from 'fs' import mkdirp from 'mkdirp' import { IncomingMessage, ServerResponse } from 'http' import Debug from 'debug' -import { ModuleNotFoundError, InvalidStatusCodeError, RecordingDisabledError } from './errors' -import { RequestHasherOptions } from './requestHasher' +import { InvalidStatusCodeError, RecordingDisabledError } from './errors' const debug = Debug('yaktime:util') @@ -15,14 +14,6 @@ export const writeFileAsync = promisify(writeFile) export const copyFileAsync = promisify(copyFile) export const readDirAsync = promisify(readdir) -/** - * Returns the tape name for `req`. - */ - -export function tapename (hashFn: RequestHasher, req: IncomingMessage, body: Buffer[] = []) { - return hashFn(req, Buffer.concat(body)) + '.js' -} - export function isValidStatusCode (code: number = 0) { return code >= 200 && code < 400 } @@ -30,6 +21,8 @@ export function isValidStatusCode (code: number = 0) { export type RequestHasher = (req: IncomingMessage, body: Buffer) => string export interface YakTimeOpts { + ignoredQueryFields?: string[] + allowedHeaders?: string[] /** * The tapes directory */ @@ -56,11 +49,6 @@ export interface YakTimeOpts { * old files will not be deleted, if `opts.hash` is defined, this will be ignored */ migrate?: boolean - /** - * Options to considerate when hashing - * this are only used when `opts.hash` is null - */ - hasherOptions?: RequestHasherOptions /** * Whether to use a built-in database instead of JS files. * To avoid issues with hashers @@ -73,22 +61,6 @@ export interface YakTimeServer { hits?: Set } -export function resolveModule (file: string): Promise { - return new Promise((resolve, reject) => { - debug(`resolve`, file) - let fileName = require.resolve(file) - - // If tape was deleted, then throw module not found error - // so that it can be re-recorded instead of failing on - // require - if (!existsSync(fileName)) { - reject(new ModuleNotFoundError('File does not exist')) - } - - resolve(fileName) - }) -} - export function ensureIsValidStatusCode (res: IncomingMessage, opts: YakTimeOpts) { if (opts.recordOnlySuccess && !isValidStatusCode(res.statusCode)) { debug('failed', 'status', res.statusCode) @@ -96,31 +68,9 @@ export function ensureIsValidStatusCode (res: IncomingMessage, opts: YakTimeOpts } } -export function ensureIsModuleNotFoundError (e: ModuleNotFoundError) { - if (e.code !== 'MODULE_NOT_FOUND') { - throw e - } -} - export function ensureRecordingIsAllowed (req: IncomingMessage, opts: YakTimeOpts) { if (opts.noRecord) { debug('no record', req.url) throw new RecordingDisabledError('Recording Disabled') } } - -export function captureAll (regex: RegExp, str: string) { - const result = [] - regex.lastIndex = 0 - let prev: RegExpExecArray | null = null - do { - prev = regex.exec(str) - if (prev != null) { - const [, ...matches] = prev - result.push(matches) - } - } while (prev != null) - - regex.lastIndex = 0 - return result -} diff --git a/src/yaktime.test.ts b/src/yaktime.test.ts index d4c7f9e..ea1e80c 100644 --- a/src/yaktime.test.ts +++ b/src/yaktime.test.ts @@ -12,6 +12,7 @@ import { AddressInfo } from 'net' import { TestServer, createServer } from './test/helpers/server' import { Dir, createTmpdir } from './test/helpers/tmpdir' import { RequestHasher } from './util' +import { getDB } from './db' const fixedUA = 'node-superagent/0.21.0' @@ -20,6 +21,9 @@ describe('yakbak', () => { let server: TestServer let serverInfo: AddressInfo let tmpdir: Dir + let db: Loki + + const tapeExists = (id: string = '-1') => db.getCollection('tapes').get(parseInt(id, 10)) != null beforeEach(async () => { server = await createServer() @@ -32,9 +36,12 @@ describe('yakbak', () => { beforeEach(async () => { tmpdir = await createTmpdir() + db = await getDB({ dirname: tmpdir.dirname }) }) afterEach(async () => { + db.removeCollection('tapes') + db.close() await tmpdir.teardown() }) @@ -50,7 +57,7 @@ describe('yakbak', () => { .get('/record/1') .set('host', 'localhost:3001') .set('user-agent', fixedUA) - .expect('X-Yakbak-Tape', '1a574e91da6cf00ac18bc97abaed139e') + .expect('X-Yakbak-Tape', /\d+/) .expect('Content-Type', 'text/html') .expect(201, 'OK') .end(function (err) { @@ -59,16 +66,16 @@ describe('yakbak', () => { }) }) - test('writes the tape to disk', done => { + test('writes the tape', done => { request(yakbak) .get('/record/2') .set('host', 'localhost:3001') .set('user-agent', fixedUA) - .expect('X-Yakbak-Tape', '3234ee470c8605a1837e08f218494326') + .expect('X-Yakbak-Tape', /\d+/) .expect('Content-Type', 'text/html') .expect(201, 'OK') - .end(function (err) { - expect(fs.existsSync(tmpdir.join('3234ee470c8605a1837e08f218494326.js'))).toBeTruthy() + .end(async (err, res) => { + expect(tapeExists(res.header['x-yakbak-tape'])).toEqual(true) done(err) }) }) @@ -96,11 +103,11 @@ describe('yakbak', () => { .query({ date: new Date() }) // without the custom hash, this would always cause 404s .set('user-agent', fixedUA) .set('host', 'localhost:3001') - .expect('X-Yakbak-Tape', '3f142e515cb24d1af9e51e6869bf666f') + .expect('X-Yakbak-Tape', /\d+/) .expect('Content-Type', 'text/html') .expect(201, 'OK') - .end(function (err) { - expect(fs.existsSync(tmpdir.join('3f142e515cb24d1af9e51e6869bf666f.js'))).toBeTruthy() + .end(function (err, res) { + expect(tapeExists(res.header['x-yakbak-tape'])).toEqual(true) done(err) }) }) @@ -180,6 +187,7 @@ describe('yakbak', () => { describe('playback', () => { let yakbak: any let yakbakWithDb: any + let id: number beforeEach(async () => { await tmpdir.teardown() await tmpdir.setup() @@ -187,38 +195,40 @@ describe('yakbak', () => { yakbakWithDb = subject(`http://localhost:${serverInfo.port}`, { dirname: tmpdir.dirname, useDb: true, migrate: true }) }) - beforeEach(done => { - let file = '305c77b0a3ad7632e51c717408d8be0f.js' - let tape = [ - 'var path = require("path");', - 'module.exports = function (req, res) {', - ' res.statusCode = 201;', - ' res.setHeader("content-type", "text/html")', - ' res.setHeader("x-yakbak-tape", path.basename(__filename, ".js"));', - ' res.end("YAY");', - '}', - '' - ].join('\n') - - fs.writeFile(tmpdir.join(file), tape, done) + beforeEach(async () => { + const result = await db.addCollection('tapes', { disableMeta: true }).add({ + path: '/playback/1', + body: '', + bodyHash: 'ef46db3751d8e999', + method: 'GET', + httpVersion: '1.1', + headers: {}, + trailers: {}, + query: {}, + response: { + statusCode: 201, + headers: { + 'content-type': 'text/html' + }, + body: Buffer.from('YAY').toString('base64') + } + }) + id = result.$loki }) - const test1 = (yak: any, done: any) => + const test1 = (yak: any) => request(yak) .get('/playback/1') .set('user-agent', fixedUA) .set('host', 'localhost:3001') - .expect('X-Yakbak-Tape', '305c77b0a3ad7632e51c717408d8be0f') + .expect('X-Yakbak-Tape', `${id}`) .expect('Content-Type', 'text/html') .expect(201, 'YAY') - .end(function (err) { - expect(server.requests.length).toEqual(0) - done(err) - }) - test('does not make a request to the server', done => { - test1(yakbak, done) - test1(yakbakWithDb, done) + test('does not make a request to the server', async () => { + await test1(yakbak) + await test1(yakbakWithDb) + expect(server.requests.length).toEqual(0) }) }) }) diff --git a/src/yaktime.ts b/src/yaktime.ts index cd173cd..6d5d56d 100644 --- a/src/yaktime.ts +++ b/src/yaktime.ts @@ -3,24 +3,16 @@ // Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms. import invariant from 'invariant' -import * as path from 'path' import Debug from 'debug' import { buffer } from './buffer' -import { recordIfNotFound } from './record' -import { mkdir, RequestHasher, YakTimeOpts, YakTimeServer, tapename, resolveModule as resolveCassete } from './util' +import { mkdir, YakTimeOpts, YakTimeServer } from './util' import { HttpError } from 'restify-errors' import * as curl from './curl' -import { migrateIfRequired } from './tapeMigrator' -import { requestHasher } from './requestHasher' -import { trackHit } from './tracker' import { Recorder } from './Recorder' const debug = Debug('yaktime:server') -const incMessH = require('incoming-message-hash') -const messageHash: RequestHasher = incMessH.sync - /** * Returns a function of the signature function (req, res) that you can give to an `http.Server` as its handler. * @param - host The hostname to proxy to @@ -30,6 +22,7 @@ const messageHash: RequestHasher = incMessH.sync // tslint:disable-next-line:cognitive-complexity export function yaktime (host: string, opts: YakTimeOpts): YakTimeServer { invariant(opts.dirname != null && opts.dirname !== '', 'You must provide opts.dirname') + const recorder = new Recorder(opts, host) const hits = new Set() const yaktimeTape: YakTimeServer = async function (req, res) { @@ -39,25 +32,12 @@ export function yaktime (host: string, opts: YakTimeOpts): YakTimeServer { debug(curl.request(req)) const body = await buffer(req) - const defaultHasher = opts.migrate ? requestHasher(opts.hasherOptions) : messageHash - const file = path.join(opts.dirname, tapename(opts.hash || defaultHasher, req, body)) try { - await migrateIfRequired(host, opts, req, body) - - if (opts.useDb === true) { - const recorder = new Recorder(opts, host) - const response = (await recorder.read(req, body)) || (await recorder.record(req, body, host, opts)) - await recorder.respond(response.response, res) - } else { - const filename = await resolveCassete(file).catch(recordIfNotFound(req, body, host, file, opts)) - - trackHit(filename, hits) - const tape: YakTimeServer = require(filename) - tape(req, res) - } + const response = (await recorder.read(req, body)) || (await recorder.record(req, res, body)) + await recorder.respond(response, res) } catch (err) { - res.statusCode = err instanceof HttpError ? err.statusCode : 500 + res.statusCode = err.statusCode || 500 debug(err.stack) res.end(err.message) } diff --git a/yarn.lock b/yarn.lock index 116c562..6575829 100644 --- a/yarn.lock +++ b/yarn.lock @@ -783,6 +783,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/http-proxy@^1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.16.2.tgz#16cb373b52fff2aa2f389d23d940ed4a642349e5" + integrity sha512-GgqePmC3rlsn1nv+kx5OviPuUBU2omhnlXOaJSXFgOdsTcScNFap+OaCb2ip9Bm4m5L8EOehgT5d9M4uNB90zg== + dependencies: + "@types/events" "*" + "@types/node" "*" + "@types/invariant@^2.2.29": version "2.2.29" resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.29.tgz#aa845204cd0a289f65d47e0de63a6a815e30cc66" @@ -1772,7 +1780,7 @@ debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: dependencies: ms "2.0.0" -debug@^3.1.0: +debug@^3.1.0, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -2335,6 +2343,11 @@ esutils@^2.0.0, esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= +eventemitter3@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" + integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== + exec-sh@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" @@ -2610,6 +2623,13 @@ fn-name@~2.0.1: resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7" integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc= +follow-redirects@^1.0.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" + integrity sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ== + dependencies: + debug "^3.2.6" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -2967,6 +2987,15 @@ http-cache-semantics@^4.0.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz#5144bcaaace7d06cfdfbab9948102b11cf9ae90b" integrity sha512-laeSTWIkuFa6lUgZAt+ic9RwOSEwbi9VDQNcCvMFO4sZiDc2Ha8DaZVCJnfpLLQCcS8rvCnIWYmz0POLxt7Dew== +http-proxy@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" + integrity sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g== + dependencies: + eventemitter3 "^3.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -5691,6 +5720,11 @@ requirejs@^2.3.5: resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9" integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"