Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wip): support "CONNECT" request #621

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -436,15 +453,15 @@ export class MockHttpSocket extends MockSocket {
path,
__,
___,
____,
upgrade,
shouldKeepAlive
) => {
this.shouldKeepAlive = shouldKeepAlive

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.
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions src/utils/createRequest.ts
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 24 additions & 3 deletions src/utils/getUrlByRequestOptions.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
118 changes: 118 additions & 0 deletions test/modules/http/regressions/http-connect.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<Buffer> = []
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)
})
Loading