From 95df373d08a1e7a15af22e19a8568d9713ab0fae Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 9 Nov 2023 15:15:44 +0100 Subject: [PATCH 1/2] feat: allow "CONNECT" request method --- .../ClientRequest/utils/createRequest.ts | 24 +++++++++++++------ .../utils/createResponse.test.ts | 4 ++-- .../ClientRequest/utils/createResponse.ts | 4 ++-- .../XMLHttpRequest/utils/createResponse.ts | 4 ++-- src/utils/fetchUtils.ts | 7 ++++++ src/utils/responseUtils.ts | 5 ---- 6 files changed, 30 insertions(+), 18 deletions(-) create mode 100644 src/utils/fetchUtils.ts delete mode 100644 src/utils/responseUtils.ts diff --git a/src/interceptors/ClientRequest/utils/createRequest.ts b/src/interceptors/ClientRequest/utils/createRequest.ts index fbc41651..3d7420c7 100644 --- a/src/interceptors/ClientRequest/utils/createRequest.ts +++ b/src/interceptors/ClientRequest/utils/createRequest.ts @@ -1,4 +1,5 @@ import type { NodeClientRequest } from '../NodeClientRequest' +import { FORBIDDEN_REQUEST_METHODS } from '../../../utils/fetchUtils' /** * Creates a Fetch API `Request` instance from the given `http.ClientRequest`. @@ -26,7 +27,9 @@ export function createRequest(clientRequest: NodeClientRequest): Request { * @see https://github.com/mswjs/interceptors/issues/438 */ if (clientRequest.url.username || clientRequest.url.password) { - const auth = `${clientRequest.url.username || ''}:${clientRequest.url.password || ''}` + const auth = `${clientRequest.url.username || ''}:${ + clientRequest.url.password || '' + }` headers.set('Authorization', `Basic ${btoa(auth)}`) // Remove the credentials from the URL since you cannot @@ -37,13 +40,20 @@ export function createRequest(clientRequest: NodeClientRequest): Request { const method = clientRequest.method || 'GET' - return new Request(clientRequest.url, { - method, + const fetchRequest = new Request(clientRequest.url, { + method: FORBIDDEN_REQUEST_METHODS.includes(method) + ? `UNSAFE-${method}` + : method, headers, credentials: 'same-origin', - body: - method === 'HEAD' || method === 'GET' - ? null - : clientRequest.requestBuffer, + body: ['HEAD', 'GET'].includes(method) ? null : clientRequest.requestBuffer, }) + + if (fetchRequest.method.startsWith('UNSAFE-')) { + Object.defineProperty(fetchRequest, 'method', { + value: fetchRequest.method.replace('UNSAFE-', ''), + }) + } + + return fetchRequest } diff --git a/src/interceptors/ClientRequest/utils/createResponse.test.ts b/src/interceptors/ClientRequest/utils/createResponse.test.ts index 42875d76..b285c18a 100644 --- a/src/interceptors/ClientRequest/utils/createResponse.test.ts +++ b/src/interceptors/ClientRequest/utils/createResponse.test.ts @@ -2,7 +2,7 @@ import { it, expect } from 'vitest' import { Socket } from 'net' import * as http from 'http' import { createResponse } from './createResponse' -import { responseStatusCodesWithoutBody } from '../../../utils/responseUtils' +import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../../utils/fetchUtils' it('creates a fetch api response from http incoming message', async () => { const message = new http.IncomingMessage(new Socket()) @@ -22,7 +22,7 @@ it('creates a fetch api response from http incoming message', async () => { expect(await response.json()).toEqual({ firstName: 'John' }) }) -it.each(responseStatusCodesWithoutBody)( +it.each(RESPONSE_STATUS_CODES_WITHOUT_BODY)( 'ignores message body for %i response status', (responseStatus) => { const message = new http.IncomingMessage(new Socket()) diff --git a/src/interceptors/ClientRequest/utils/createResponse.ts b/src/interceptors/ClientRequest/utils/createResponse.ts index 221fb665..c49355cf 100644 --- a/src/interceptors/ClientRequest/utils/createResponse.ts +++ b/src/interceptors/ClientRequest/utils/createResponse.ts @@ -1,12 +1,12 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'http' -import { responseStatusCodesWithoutBody } from '../../../utils/responseUtils' +import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../../utils/fetchUtils' /** * Creates a Fetch API `Response` instance from the given * `http.IncomingMessage` instance. */ export function createResponse(message: IncomingMessage): Response { - const responseBodyOrNull = responseStatusCodesWithoutBody.includes( + const responseBodyOrNull = RESPONSE_STATUS_CODES_WITHOUT_BODY.includes( message.statusCode || 200 ) ? null diff --git a/src/interceptors/XMLHttpRequest/utils/createResponse.ts b/src/interceptors/XMLHttpRequest/utils/createResponse.ts index 8c7dacd6..d54aac9c 100644 --- a/src/interceptors/XMLHttpRequest/utils/createResponse.ts +++ b/src/interceptors/XMLHttpRequest/utils/createResponse.ts @@ -1,4 +1,4 @@ -import { responseStatusCodesWithoutBody } from '../../../utils/responseUtils' +import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../../utils/fetchUtils' /** * Creates a Fetch API `Response` instance from the given @@ -16,7 +16,7 @@ export function createResponse( * when constructing a Response instance. * @see https://github.com/mswjs/interceptors/issues/379 */ - const responseBodyOrNull = responseStatusCodesWithoutBody.includes( + const responseBodyOrNull = RESPONSE_STATUS_CODES_WITHOUT_BODY.includes( request.status ) ? null diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts new file mode 100644 index 00000000..904a9a0f --- /dev/null +++ b/src/utils/fetchUtils.ts @@ -0,0 +1,7 @@ +export const FORBIDDEN_REQUEST_METHODS = ['CONNECT', 'TRACK', 'TRACE'] + +/** + * Response status codes for responses that cannot have body. + * @see https://fetch.spec.whatwg.org/#statuses + */ +export const RESPONSE_STATUS_CODES_WITHOUT_BODY = [204, 205, 304] diff --git a/src/utils/responseUtils.ts b/src/utils/responseUtils.ts deleted file mode 100644 index 19ef310b..00000000 --- a/src/utils/responseUtils.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Response status codes for responses that cannot have body. - * @see https://fetch.spec.whatwg.org/#statuses - */ -export const responseStatusCodesWithoutBody = [204, 205, 304] From be7838fbff27ee5ba083ccfe4e15a21c0429147c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 11 Nov 2023 15:27:04 +0100 Subject: [PATCH 2/2] wip: socket-based interception --- package.json | 1 + pnpm-lock.yaml | 7 + proxy.js | 64 ++++ .../ClientRequest/NodeClientRequest.ts | 20 ++ src/interceptors/Socket/index.ts | 320 ++++++++++++++++++ .../XMLHttpRequestController.ts | 11 +- src/utils/getUrlByRequestOptions.ts | 16 +- test/modules/Socket/socket.test.ts | 126 +++++++ .../http/compliance/http-connect.test.ts | 82 +++++ test/third-party/got-hpagent-proxy.test.ts | 65 ++++ 10 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 proxy.js create mode 100644 src/interceptors/Socket/index.ts create mode 100644 test/modules/Socket/socket.test.ts create mode 100644 test/modules/http/compliance/http-connect.test.ts create mode 100644 test/third-party/got-hpagent-proxy.test.ts diff --git a/package.json b/package.json index 3ef19494..df6a84bb 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "follow-redirects": "^1.15.1", "got": "^11.8.3", "happy-dom": "^12.10.3", + "hpagent": "^1.2.0", "jest": "^27.4.3", "node-fetch": "2.6.7", "rimraf": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04f438bf..685baa63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,7 @@ specifiers: follow-redirects: ^1.15.1 got: ^11.8.3 happy-dom: ^12.10.3 + hpagent: ^1.2.0 is-node-process: ^1.2.0 jest: ^27.4.3 node-fetch: 2.6.7 @@ -82,6 +83,7 @@ devDependencies: follow-redirects: 1.15.2 got: 11.8.6 happy-dom: 12.10.3 + hpagent: 1.2.0 jest: 27.5.1 node-fetch: 2.6.7 rimraf: 3.0.2 @@ -3962,6 +3964,11 @@ packages: lru-cache: 6.0.0 dev: true + /hpagent/1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + dev: true + /html-encoding-sniffer/2.0.1: resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==} engines: {node: '>=10'} diff --git a/proxy.js b/proxy.js new file mode 100644 index 00000000..0d4e046c --- /dev/null +++ b/proxy.js @@ -0,0 +1,64 @@ +const http = require('node:http') +const net = require('node:net') +const { URL } = require('node:url') + +// Create an HTTP tunneling proxy +const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('okay') +}) +proxy.on('connect', (req, clientSocket, head) => { + console.log('\n\nproxy.on(connect)', req.method, req.url, { + clientSocket, + head: head.toString('utf8'), + }) + + // Connect to an origin server + const { port, hostname } = new URL(`http://${req.url}`) + const serverSocket = net.connect(port || 80, hostname, () => { + clientSocket.write( + 'HTTP/1.1 200 Connection Established\r\n' + + 'Proxy-agent: Node.js-Proxy\r\n' + + '\r\n' + ) + serverSocket.write(head) + serverSocket.pipe(clientSocket) + clientSocket.pipe(serverSocket) + }) +}) + +// Now that proxy is running +proxy.listen(1337, '127.0.0.1', () => { + // Make a request to a tunneling proxy + const options = { + port: 1337, + host: '127.0.0.1', + method: 'CONNECT', + path: 'www.google.com:80', + } + + const req = http.request(options) + console.log('req.end()') + req.end() + + req.on('connect', (res, socket, head) => { + console.log('============='.repeat(10)) + console.log('\n\n req.on(connect)') + console.group({ res, socket, head: head.toString('utf8') }) + console.log(res.statusCode, res.statusMessage) + + // Make a request over an HTTP tunnel + socket.write( + 'GET / HTTP/1.1\r\n' + + 'Host: www.google.com:80\r\n' + + 'Connection: close\r\n' + + '\r\n' + ) + socket.on('data', (chunk) => { + console.log('incoming chunk...') + }) + socket.on('end', () => { + proxy.close() + }) + }) +}) diff --git a/src/interceptors/ClientRequest/NodeClientRequest.ts b/src/interceptors/ClientRequest/NodeClientRequest.ts index 0bdec77f..b4745cc0 100644 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ b/src/interceptors/ClientRequest/NodeClientRequest.ts @@ -1,4 +1,5 @@ import { ClientRequest, IncomingMessage } from 'http' +import { Duplex } from 'stream' import type { Logger } from '@open-draft/logger' import { until } from '@open-draft/until' import { DeferredPromise } from '@open-draft/deferred-promise' @@ -577,6 +578,25 @@ export class NodeClientRequest extends ClientRequest { this.terminate() this.logger.info('request complete!') + + if (this.method === 'CONNECT') { + console.warn('SHOULD EMIT CONNECT', typeof this.response) + + const head = Buffer.from('') + + const socket = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + console.log( + 'CONNECT event Duplex write:\n', + `== START ==\n${chunk.toString('utf8')}== END ==` + ) + callback() + }, + }) + + this.emit('connect', this.response, socket, head) + } }) } diff --git a/src/interceptors/Socket/index.ts b/src/interceptors/Socket/index.ts new file mode 100644 index 00000000..4d350500 --- /dev/null +++ b/src/interceptors/Socket/index.ts @@ -0,0 +1,320 @@ +import net from 'node:net' +import http, { IncomingMessage } from 'node:http' +import { Interceptor } from '../..' +import EventEmitter from 'node:events' +import { Readable } from 'stream' + +export type SocketEventsMap = { + socket: [SocketEvent] +} + +export class SocketInterceptor extends Interceptor { + static interceptorSymbol = Symbol('socket') + + constructor() { + super(SocketInterceptor.interceptorSymbol) + } + + protected setup(): void { + /** + * "new net.Socket()". + */ + const socketProxy = Proxy.revocable(net.Socket, { + construct: (target, args, newTarget) => { + const socket = new SocketController(args[0]) + this.emitter.emit('socket', new SocketEvent(socket)) + return socket + }, + }) + net.Socket = socketProxy.proxy + this.subscriptions.push(() => { + socketProxy.revoke() + }) + + /** + * "net.createConnection()". + */ + const netCreateConnectionProxy = Proxy.revocable(net.createConnection, { + apply: (target, thisArg, args) => { + console.log('net.createConnection()', args) + const socket = new SocketController(args[0]) + + const event = new SocketEvent(socket) + this.emitter.emit('socket', event) + + return socket + }, + }) + net.createConnection = netCreateConnectionProxy.proxy + this.subscriptions.push(() => netCreateConnectionProxy.revoke()) + + /** + * "http.*()". + */ + const httpGetProxy = Proxy.revocable(http.get, { + apply: (target, context, args) => { + const clientRequest = new ClientRequestController(args) + + clientRequest.once('socket', (socket) => { + const event = new SocketEvent(socket) + this.emitter.emit('socket', event) + + event.on('data', () => { + // @ts-ignore + clientRequest.res = new IncomingMessage(socket) + }) + }) + + return clientRequest + }, + }) + http.get = httpGetProxy.proxy + this.subscriptions.push(() => httpGetProxy.revoke()) + } +} + +/** + * ClientRequest. + */ +class ClientRequestController extends http.ClientRequest { + // private response: http.IncomingMessage + // private socketEvent: SocketEvent = null as any + + constructor(...args: [any]) { + super(...args) + + // Reflect.set(this, 'socket', new SocketController()) + + // this.on('socket', (socket) => { + // this.socketEvent = new SocketEvent(socket) + // }) + + // this.response = new http.IncomingMessage(this.socket!) + + this.once('socket', (socket) => { + let [socketOnData] = socket.listeners('data') + + socket['_events'].data = new Proxy(socketOnData, { + apply(target, context, args) { + console.log('----- socketOnData!', args[0].toString('utf8')) + return Reflect.apply(target, context, args) + }, + }) + }) + } + + emit(event: 'close'): boolean + emit(event: 'drain'): boolean + emit(event: 'error', err: Error): boolean + emit(event: 'finish'): boolean + emit(event: 'pipe', src: Readable): boolean + emit(event: 'unpipe', src: Readable): boolean + emit(event: string | symbol, ...args: any[]): boolean + emit(...args: [any]): boolean { + console.log('request emit', args) + + if (args[0] === 'error') { + console.log('haderror?', this.socket._hadError) + Reflect.set(this.socket, '_hadError', false) + } + + if (args[0] === 'error') { + return false + } + + return super.emit(...args) + } +} + +/** + * Socket. + */ + +const DEFAULT_CONNECTION_IP = '127.0.0.1' +const DEFAULT_CONNECTION_HOST = 'localhost' +const DEFAULT_CONNECTION_PORT = 80 +const DEFAULT_CONNECTION_PATH = '/' +const DEFAULT_ADDRESS_FAMILY = 6 + +class SocketEvent extends EventEmitter { + constructor(protected readonly socket: net.Socket) { + super() + + this.socket._hadError = new Proxy(this.socket._hadError, { + set(target, property, nextValue) { + throw new Error('had err') + return false + }, + }) + + this.socket.emit = new Proxy(this.socket.emit, { + apply: (target, context, args) => { + console.log('socket.emit', args) + + if (args[0] === 'error' || args[0] === 'close') { + return false + } + + if (args[0] === 'data') { + console.log('socket emit data:', args[1].toString('utf8')) + } + + return Reflect.apply(target, context, args) + }, + }) + + this.socket._destroy = (error, callback) => { + console.log('socket destroy?', error) + callback(null) + } + + this.socket._write = (chunk, encoding, callback) => { + this.emit('data', chunk, encoding) + callback() + } + + this.socket.pause() + } + + public connect(connectionOptions?: { + host?: string + family?: string | number + ipAddress?: string + }): void { + const ip = connectionOptions?.ipAddress || DEFAULT_CONNECTION_IP + const family = connectionOptions?.family + const host = connectionOptions?.host || this.socket['_host'] + + console.log('SocketEvent.connect()') + + process.nextTick(() => { + console.log('SocketEvent: emitting lookup...') + this.socket.emit('lookup', null, ip, family, host) + console.log('SocketEvent: emitting connect...') + this.socket.emit('connect') + console.log('SocketEvent: emitting ready...') + this.socket.emit('ready') + + this.socket.resume() + }) + } + + /** + * Listen to the outgoing socket chunks (e.g. request data). + */ + public on( + event: 'data', + callback: (chunk: Buffer, encoding?: BufferEncoding) => void + ) { + return super.on(event, callback) + } + + /** + * Write chunks to the socket from the server's perspective. + */ + public push(chunk?: Buffer | string | null, encoding?: BufferEncoding): void { + this.socket.push(chunk, encoding) + } +} + +class SocketController extends net.Socket { + private _connection: { + host: string + port: number + path: string + family: number | string + } + + constructor(protected readonly options?: net.SocketConstructorOpts) { + super(options) + + this._connection = { + /** + * @note Although type-wise the "Socket" constructor cannot accept the + * connection options, this isn't what happens on runtime. For example, + * when called "net.createConnection()", the "net" module passes the + * connection options directly on to the new "Socket" instance. + */ + host: Reflect.get(this.options || {}, 'host') || DEFAULT_CONNECTION_HOST, + port: Reflect.get(this.options || {}, 'port') || DEFAULT_CONNECTION_PORT, + path: Reflect.get(this.options || {}, 'path') || DEFAULT_CONNECTION_PATH, + family: + Reflect.get(this.options || {}, 'family') || DEFAULT_ADDRESS_FAMILY, + } + } + + connect( + options: net.SocketConnectOpts, + connectionListener?: (() => void) | undefined + ): this + connect( + port: number, + host: string, + connectionListener?: (() => void) | undefined + ): this + connect(port: number, connectionListener?: (() => void) | undefined): this + connect(path: string, connectionListener?: (() => void) | undefined): this + connect(...args: Array): this { + if (typeof args[0] === 'number') { + this._connection.port = args[0] + + if (typeof args[1] === 'string') { + this._connection.host = args[1] + } + + return this + } + + if (typeof args[0] === 'string') { + this._connection.path = args[0] + return this + } + + if (typeof args[0] === 'object' && args[0] != null) { + if ('path' in args[0] && typeof args[0].path === 'string') { + this._connection.path = args[0].path + } + + if ('host' in args[0] && typeof args[0].host === 'string') { + this._connection.host = args[0].host + } + + if ('port' in args[0] && typeof args[0].port === 'number') { + this._connection.port = args[0].port + } + + if ( + 'family' in args[0] && + (typeof args[0].family == 'number' || + typeof args[0].family === 'string') + ) { + this._connection.family = args[0].family + } + } + + return this + } + + public triggerConnect(connectionOptions?: { + host?: string + family?: number | string + }) { + const host = connectionOptions?.host || this.connection.host + const family = connectionOptions?.family || this.connection.family + + process.nextTick(() => { + this.emit('lookup', null, '127.0.0.1', family, host) + this.emit('connect') + this.emit('ready') + }) + + return this + } +} + +function getProperty( + target: T | undefined, + key: keyof T +): T[typeof key] | undefined { + return Reflect.get(target || {}, key) +} diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 84baa4bf..b611ece0 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -13,6 +13,7 @@ import { isDomParserSupportedType } from './utils/isDomParserSupportedType' import { parseJson } from '../../utils/parseJson' import { uuidv4 } from '../../utils/uuid' import { createResponse } from './utils/createResponse' +import { FORBIDDEN_REQUEST_METHODS } from '../../utils/fetchUtils' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const IS_NODE = isNodeProcess() @@ -551,7 +552,9 @@ export class XMLHttpRequestController { this.logger.info('converting request to a Fetch API Request...') const fetchRequest = new Request(this.url.href, { - method: this.method, + method: FORBIDDEN_REQUEST_METHODS.includes(this.method) + ? `UNSAFE-${this.method}` + : this.method, headers: this.requestHeaders, /** * @see https://xhr.spec.whatwg.org/#cross-origin-credentials @@ -562,6 +565,12 @@ export class XMLHttpRequestController { : (this.requestBody as any), }) + if (fetchRequest.method.startsWith('UNSAFE-')) { + Object.defineProperty(fetchRequest, 'method', { + value: fetchRequest.method.replace('UNSAFE-', ''), + }) + } + const proxyHeaders = createProxy(fetchRequest.headers, { methodCall: ([methodName, args], invoke) => { // Forward the latest state of the internal request headers diff --git a/src/utils/getUrlByRequestOptions.ts b/src/utils/getUrlByRequestOptions.ts index 72d16134..35002934 100644 --- a/src/utils/getUrlByRequestOptions.ts +++ b/src/utils/getUrlByRequestOptions.ts @@ -155,7 +155,21 @@ export function getUrlByRequestOptions(options: ResolvedRequestOptions): URL { const hostname = getHostname(host, port) logger.info('hostname', hostname) - const path = options.path || DEFAULT_PATH + const path = options.path + ? /** + * @FIXME THIS IS INCORRECT! + * CONNECT options of ClientRequest can have "path" pointing to the origin host. + * @see https://nodejs.org/docs/latest-v18.x/api/http.html#event-connect (example) + * + * @note Some clients provide the "path" option that + * does not start with a leading slash. Prepend it + * so the normalized URL can be constructed correctly. + * @see https://github.com/delvedor/hpagent/blob/96f45f1d40bfbdfd0fcc84cdba056be6e0fb8f4c/index.js#L23 + */ + options.path.startsWith('/') + ? options.path + : `/${options.path}` + : DEFAULT_PATH logger.info('path', path) const credentials = getAuthByRequestOptions(options) diff --git a/test/modules/Socket/socket.test.ts b/test/modules/Socket/socket.test.ts new file mode 100644 index 00000000..45e5d0b1 --- /dev/null +++ b/test/modules/Socket/socket.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, it, expect, vi } from 'vitest' +import net from 'node:net' +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { SocketInterceptor } from '../../../src/interceptors/Socket' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('supports raw net.Socket usage', async () => { + interceptor.once('socket', (event) => { + event.push('hello from server') + event.push(null) + }) + + const socket = new net.Socket() + socket.on('lookup', (error, address, family, host) => { + console.log('lookup', { error, address, family, host }) + }) + + socket.write('data from client') + socket.end() + + socket.on('data', (chunk) => { + console.log('client sent data:', chunk.toString('utf-8')) + }) + + const endPromise = new DeferredPromise() + socket.on('end', () => endPromise.resolve()) + + await endPromise +}) + +it('supports net.createConnection()', async () => { + interceptor.once('socket', (event) => { + // Explicitly allow the connection. + // When called without any arguments, will connect to + // the same host/port specified in "net.createConnect()". + // Can also provide overrides for host/port to emulate + // the connection elsewhere. + event.connect() + + event.on('data', (chunk) => { + event.push(`Hello, ${chunk}`) + event.push(null) + }) + }) + + const socket = net.createConnection({ + host: 'non-existing-host.com', + port: 80, + path: '/', + }) + + const lookupListener = vi.fn() + socket.on('lookup', lookupListener) + + const connectListener = vi.fn() + socket.on('connect', connectListener) + + socket.write('John') + socket.end() + + socket.on('data', (chunk) => + console.log('from server:', chunk.toString('utf8')) + ) + + const endPromise = new DeferredPromise() + socket.on('end', () => endPromise.resolve()) + await endPromise + + expect(lookupListener).toHaveBeenCalledWith( + null, + '127.0.0.1', + 4, + 'non-existing-host.com' + ) + expect(connectListener).toHaveBeenCalledTimes(1) +}) + +it.only('http.get', async () => { + interceptor.once('socket', (event) => { + console.log('--> INTERCEPTED SOCKET!') + event.connect() + + event.on('data', (chunk) => console.log('request sent:', chunk)) + + event.push(Buffer.from('HTTP/1.1 200 OK\r\n' + 'Connection: close\r\n')) + event.push(null) + }) + + const request = http.get('http://example.com/resource') + + await new Promise((resolve) => { + request.on('socket', (socket) => { + console.log('req socket!') + + socket.on('connect', () => console.log('socket connect!')) + socket.on('lookup', (...args) => console.log('socket lookup', args)) + socket.on('ready', () => console.log('--> socket READY!')) + socket.on('error', (error) => console.log('socket error', error)) + socket.on('timeout', () => console.log('socket timeout!')) + + socket.on('ready', () => resolve()) + socket.on('data', (data) => + console.log('socket data:', data.toString('utf8')) + ) + }) + }) + + const responsePromise = new DeferredPromise() + request.on('response', (response) => { + console.log('--> RESPONSE!') + responsePromise.resolve(response) + }) + + const response = await responsePromise + // expect(response.statusCode).toBe(200) +}) diff --git a/test/modules/http/compliance/http-connect.test.ts b/test/modules/http/compliance/http-connect.test.ts new file mode 100644 index 00000000..b25cec0d --- /dev/null +++ b/test/modules/http/compliance/http-connect.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, expect, it } from 'vitest' +import http from 'node:http' +import type { Socket } from 'node:net' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('', async () => { + interceptor.on('request', ({ request }) => { + // Handle the "CONNECT" request to the target host. + if (request.method === 'CONNECT') { + return request.respondWith( + new Response(null, { + status: 200, + statusText: 'Connection Established', + }) + ) + } + + const url = new URL(request.url) + + // Handle the request against the target. + if (url.hostname === 'www.example.com') { + console.log(request) + return request.respondWith(new Response('Hello world')) + } + }) + + const request = http.request('', { + method: 'CONNECT', + host: '127.0.0.1', + port: 1234, + /** + * @note For "CONNECT" request methods, + * the "path" option is expected to equal to + * the next target (when proxying). + */ + path: 'www.example.com:80', + }) + request.end() + + const connectPromise = new DeferredPromise< + [http.IncomingMessage, Socket, Buffer] + >() + + request.on('connect', (response, socket, head) => { + connectPromise.resolve([response, socket, head]) + }) + + const [response, socket, head] = await connectPromise + + // IncomingMessage sent to the request's "connect" event + // is the initial "CONNECT" response from the server. + expect(response.statusCode).toBe(200) + expect(response.statusMessage).toBe('Connection Established') + + // Must receive empty head since none was sent from the server. + expect(head.byteLength).toBe(0) + + // Make additional requests against the target host. + socket.write( + [ + 'GET /resource HTTP/1.1', + 'Host: www.example.com:80', + 'Connection:close', + '', + ].join('\r\n') + ) + + socket.on('data', (chunk) => { + console.log('from server:', chunk.toString('utf8')) + }) +}) diff --git a/test/third-party/got-hpagent-proxy.test.ts b/test/third-party/got-hpagent-proxy.test.ts new file mode 100644 index 00000000..8f02dc35 --- /dev/null +++ b/test/third-party/got-hpagent-proxy.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, afterAll, it } from 'vitest' +import got from 'got' +import { HttpProxyAgent } from 'hpagent' +import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('', async () => { + interceptor.once('request', ({ request }) => { + console.log( + 'REQUEST', + request.method, + /** + * @todo "request.url" must already be the TARGET url + * ("http://username:password@fake.proxy:443") + */ + request.url + ) + + // First, the interceptor must handle the "CONNECT" request. + // Here, it decides whether to allow connection to the proxy target. + if (request.method === 'CONNECT') { + return request.respondWith( + new Response(null, { + status: 200, + statusText: 'Connection Established', + }) + ) + } + + console.log('NOT HANDLED!') + }) + + const proxyHost = 'fake.proxy' + const username = 'proxyUsername' + const password = 'proxyPassword' + const port = '443' + const PING_URL = 'http://fake.ping/pinging' + + const proxyAgent = new HttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxSockets: 256, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: `http://${username}:${password}@${proxyHost}:${port}`, + }) + + const response = await got.get({ + url: PING_URL, + agent: { + http: proxyAgent, + }, + }) + + console.log('response done!') +})