From 0922d021a231a5e85f84599eec1b6911d550e974 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 13 Aug 2024 21:59:20 +0200 Subject: [PATCH] feat(wip): support "CONNECT" request --- .../ClientRequest/MockHttpSocket.ts | 40 ++++-- src/utils/createRequest.ts | 48 +++++++ src/utils/getUrlByRequestOptions.ts | 27 +++- .../http/regressions/http-connect.test.ts | 118 ++++++++++++++++++ 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 src/utils/createRequest.ts create mode 100644 test/modules/http/regressions/http-connect.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 545faee9..1fc55601 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -19,6 +19,11 @@ import { } from '../../utils/responseUtils' import { createRequestId } from '../../createRequestId' import { getRawFetchHeaders } from './utils/recordRawHeaders' +import { + createRequest, + isBodyAllowedForMethod, +} from '../../utils/createRequest' +import { canParseUrl } from '../../../src/utils/canParseUrl' type HttpConnectionOptions = any @@ -230,9 +235,16 @@ export class MockHttpSocket extends MockSocket { } socket - .on('lookup', (...args) => this.emit('lookup', ...args)) + .on('lookup', (...args) => this.emit.call(this, 'lookup', args)) .on('connect', () => { this.connecting = socket.connecting + + /** + * @fixme @todo net.Socket does NOT provide any arguments + * on the `connect` event. The (res, socket, head) args + * must be http.ClientRequest's doing. Investigate. + */ + this.emit('connect') }) .on('secureConnect', () => this.emit('secureConnect')) @@ -336,6 +348,11 @@ export class MockHttpSocket extends MockSocket { serverResponse.destroy() }) + if (this.request?.method === 'CONNECT') { + console.log('CONNECT!') + this.emit('connect', serverResponse, this) + } + if (response.body) { try { const reader = response.body.getReader() @@ -436,7 +453,7 @@ export class MockHttpSocket extends MockSocket { path, __, ___, - ____, + upgrade, shouldKeepAlive ) => { this.shouldKeepAlive = shouldKeepAlive @@ -444,7 +461,7 @@ export class MockHttpSocket extends MockSocket { const url = new URL(path, this.baseUrl) const method = this.connectionOptions.method?.toUpperCase() || 'GET' const headers = parseRawHeaders(rawHeaders) - const canHaveBody = method !== 'GET' && method !== 'HEAD' + const canHaveBody = isBodyAllowedForMethod(method) // Translate the basic authorization in the URL to the request header. // Constructing a Request instance with a URL containing auth is no-op. @@ -478,15 +495,24 @@ export class MockHttpSocket extends MockSocket { } const requestId = createRequestId() - this.request = new Request(url, { + this.request = createRequest(url, { method, headers, credentials: 'same-origin', - // @ts-expect-error Undocumented Fetch property. - duplex: canHaveBody ? 'half' : undefined, - body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, + body: canHaveBody + ? (Readable.toWeb(this.requestStream!) as any) + : undefined, }) + // this.request = new Request(url, { + // method, + // headers, + // credentials: 'same-origin', + // // @ts-expect-error Undocumented Fetch property. + // duplex: canHaveBody ? 'half' : undefined, + // body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, + // }) + Reflect.set(this.request, kRequestId, requestId) // Skip handling the request that's already being handled diff --git a/src/utils/createRequest.ts b/src/utils/createRequest.ts new file mode 100644 index 00000000..2c08eda5 --- /dev/null +++ b/src/utils/createRequest.ts @@ -0,0 +1,48 @@ +const REQUEST_METHODS_WITHOUT_BODY = ['CONNECT', 'HEAD', 'GET'] +const FORBIDDEN_REQUEST_METHODS = ['CONNECT'] + +const kOriginalMethod = Symbol('kOriginalMethod') + +export function isBodyAllowedForMethod(method: string): boolean { + return !REQUEST_METHODS_WITHOUT_BODY.includes(method) +} + +export function createRequest( + info: RequestInfo | URL, + init: RequestInit +): Request { + const method = init.method?.toUpperCase() || 'GET' + const canHaveBody = isBodyAllowedForMethod(method) + const isMethodAllowed = !FORBIDDEN_REQUEST_METHODS.includes(method) + + // Support unsafe request methods. + if (init.method && !isMethodAllowed) { + init.method = `UNSAFE-${init.method}` + } + + // Automatically set the undocumented `duplex` option from Undici + // for POST requests with body. + if (canHaveBody) { + if (!Reflect.has(init, 'duplex')) { + Object.defineProperty(init, 'duplex', { + value: 'half', + enumerable: true, + writable: true, + }) + } + } else { + // Force the request body to undefined in case of request methods + // that cannot have a body. A convenience behavior. + init.body = undefined + } + + const request = new Request(info, init) + + if (!isMethodAllowed) { + Object.defineProperty(request, 'method', { + value: method, + }) + } + + return request +} diff --git a/src/utils/getUrlByRequestOptions.ts b/src/utils/getUrlByRequestOptions.ts index 0b8a5ef7..fe7636e1 100644 --- a/src/utils/getUrlByRequestOptions.ts +++ b/src/utils/getUrlByRequestOptions.ts @@ -1,6 +1,7 @@ import { Agent } from 'http' import { RequestOptions, Agent as HttpsAgent } from 'https' import { Logger } from '@open-draft/logger' +import { canParseUrl } from './canParseUrl' const logger = new Logger('utils getUrlByRequestOptions') @@ -94,7 +95,7 @@ function getHostname(options: ResolvedRequestOptions): string | undefined { if (host) { if (isRawIPv6Address(host)) { - host = `[${host}]` + host = `[${host}]` } // Check the presence of the port, and if it's present, @@ -141,12 +142,32 @@ export function getUrlByRequestOptions(options: ResolvedRequestOptions): URL { : '' logger.info('auth string:', authString) - const portString = typeof port !== 'undefined' ? `:${port}` : '' - const url = new URL(`${protocol}//${hostname}${portString}${path}`) + if (canParseUrl(path)) { + return new URL(path) + } + + /** + * @fixme Path scenarios: + * "www.google.com is" NOT valid. + * "www.google.com:80" IS valid. + * "127.0.0.1" is NOT valid. + * + * See how Node understands what is a URL pathname and what is a proxy + * target `path`? + */ + const resolvedPath = canParseUrl(path) ? '' : path + + console.log({ protocol, hostname, path, port }) + + const url = new URL(`${protocol}//${hostname}${resolvedPath}`) + + url.port = port ? port.toString() : '' url.username = credentials?.username || '' url.password = credentials?.password || '' logger.info('created url:', url) + console.log('RESULT:', url.href) + return url } diff --git a/test/modules/http/regressions/http-connect.test.ts b/test/modules/http/regressions/http-connect.test.ts new file mode 100644 index 00000000..2cbfcc1d --- /dev/null +++ b/test/modules/http/regressions/http-connect.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment node +import http from 'node:http' +import net from 'node:net' +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpServer } from '@open-draft/test-server/http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new ClientRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.connect('/', (req, res) => { + console.log('[server] CONNECT!') + res.status(200).end() + }) + + app.get('/proxy', (req, res) => res.send('hello')) +}) + +const server = http.createServer((req, res) => { + if (req.url === '/resource') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.write('one') + res.write('two') + res.end('hello world') + return + } +}) + +server.on('connect', (req, clientSocket, head) => { + console.log('[server] CONNECT!', req.url) + + const { port, hostname } = new URL(`http://${req.url}`) + + console.log(req.url, { port, hostname }) + + const socket = net.connect(Number(port || 80), hostname, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + socket.write(head) + socket.pipe(clientSocket) + clientSocket.pipe(socket) + + console.log('[server] CONNECT handled!') + }) +}) + +beforeAll(async () => { + interceptor.apply() + await new Promise((resolve) => { + server.listen(56690, () => resolve()) + }) + // await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + server.close() + // await httpServer.close() +}) + +it('mocks a CONNECT request', async () => { + // interceptor.on('request', ({ request, controller }) => { + // console.log('request!', request.method, request.url) + + // if (request.method === 'CONNECT') { + // return controller.respondWith( + // new Response(null, { + // status: 200, + // statusText: 'Connection Established', + // }) + // ) + // } + // }) + + // interceptor.on('request', ({ request }) => { + // console.log('INTERCEPTED', request.method, request.url) + // }) + + const request = http + .request({ + method: 'CONNECT', + // host: httpServer.http.address.host, + // port: httpServer.http.address.port, + + // Path indicates the target URL for the CONNECT request. + // path: httpServer.http.url('/proxy'), + + host: '127.0.0.1', + port: 56690, + path: 'www.google.com:80', + }) + .end() + + request.on('connect', (response, socket, head) => { + console.log('[request] CONNECT', response.statusCode, response.url) + + // Once the server handles the "CONNECT" request, the client can communicate + // with the connected proxy ("path") using the `socket` instance. + socket.write( + 'GET /resource HTTP/1.1\r\nHost: www.google.com:80\r\nConnection: close\r\n\r\n' + ) + + let chunks: Array = [] + socket.on('data', (chunk) => { + chunks.push(chunk) + }) + socket.on('end', () => { + console.log('BODY:', Buffer.concat(chunks).toString('utf8')) + request.destroy() + }) + }) + + const { res } = await waitForClientRequest(request) +})