/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import dns from 'dns'; import http from 'http'; import https from 'https'; import net from 'net'; import tls from 'tls'; import { assert } from '@isomorphic/assert'; import { ManualPromise } from '@isomorphic/manualPromise'; import { monotonicTime } from '@isomorphic/time'; // Implementation(partial) of Happy Eyeballs 2 algorithm described in // https://www.rfc-editor.org/rfc/rfc8305 // Same as in Chromium (https://source.chromium.org/chromium/chromium/src/+/5666ff4f5077a7e2f72902f3a95f5d553ea0d88d:net/socket/transport_connect_job.cc;l=102) const connectionAttemptDelayMs = 300; const kDNSLookupAt = Symbol('kDNSLookupAt'); const kTCPConnectionAt = Symbol('kTCPConnectionAt'); class HttpHappyEyeballsAgent extends http.Agent { createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket?: net.Socket) => void): net.Socket | undefined { // There is no ambiguity in case of IP address. if (net.isIP(clientRequestArgsToHostName(options))) return net.createConnection(options as net.NetConnectOpts); createConnectionAsync(options, oncreate, /* useTLS */ false).catch(err => oncreate?.(err)); } } class HttpsHappyEyeballsAgent extends https.Agent { createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket?: net.Socket) => void): net.Socket | undefined { // There is no ambiguity in case of IP address. if (net.isIP(clientRequestArgsToHostName(options))) return tls.connect(options as tls.ConnectionOptions); createConnectionAsync(options, oncreate, /* useTLS */ true).catch(err => oncreate?.(err)); } } // These options are aligned with the default Node.js globalAgent options. export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent({ keepAlive: true }); export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent({ keepAlive: true }); export async function createSocket(host: string, port: number): Promise { return new Promise((resolve, reject) => { if (net.isIP(host)) { const socket = net.createConnection({ host, port }); socket.on('connect', () => resolve(socket)); socket.on('error', error => reject(error)); } else { createConnectionAsync({ host, port }, (err, socket) => { if (err) reject(err); if (socket) resolve(socket); }, /* useTLS */ false).catch(err => reject(err)); } }); } export async function createTLSSocket(options: tls.ConnectionOptions): Promise { return new Promise((resolve, reject) => { assert(options.host, 'host is required'); if (net.isIP(options.host)) { const socket = tls.connect(options); socket.on('secureConnect', () => resolve(socket)); socket.on('error', error => reject(error)); } else { createConnectionAsync(options, (err, socket) => { if (err) reject(err); if (socket) { socket.on('secureConnect', () => resolve(socket)); socket.on('error', error => reject(error)); } }, true).catch(err => reject(err)); } }); } export async function createConnectionAsync( options: http.ClientRequestArgs, oncreate: ((err: Error | null, socket?: tls.TLSSocket) => void) | undefined, useTLS: true ): Promise; export async function createConnectionAsync( options: http.ClientRequestArgs, oncreate: ((err: Error | null, socket?: net.Socket) => void) | undefined, useTLS: false ): Promise; export async function createConnectionAsync( options: http.ClientRequestArgs, oncreate: ((err: Error | null, socket?: any) => void) | undefined, useTLS: boolean ): Promise { const lookup = (options as any).__testHookLookup || lookupAddresses; const hostname = clientRequestArgsToHostName(options); const addresses = await lookup(hostname); const dnsLookupAt = monotonicTime(); const sockets = new Set(); let firstError; let errorCount = 0; const handleError = (socket: net.Socket, err: Error) => { if (!sockets.delete(socket)) return; ++errorCount; firstError ??= err; if (errorCount === addresses.length) oncreate?.(firstError); }; const connected = new ManualPromise(); for (const { address } of addresses) { const socket = useTLS ? tls.connect({ ...(options as tls.ConnectionOptions), port: options.port as number, host: address, servername: hostname }) : net.createConnection({ ...options, port: options.port as number, host: address }); (socket as any)[kDNSLookupAt] = dnsLookupAt; // Each socket may fire only one of 'connect', 'timeout' or 'error' events. // None of these events are fired after socket.destroy() is called. socket.on('connect', () => { (socket as any)[kTCPConnectionAt] = monotonicTime(); connected.resolve(); oncreate?.(null, socket); // TODO: Cache the result? // Close other outstanding sockets. sockets.delete(socket); for (const s of sockets) s.destroy(); sockets.clear(); }); socket.on('timeout', () => { // Timeout is not an error, so we have to manually close the socket. socket.destroy(); handleError(socket, new Error('Connection timeout')); }); socket.on('error', e => handleError(socket, e)); sockets.add(socket); await Promise.race([ connected, new Promise(f => setTimeout(f, connectionAttemptDelayMs)) ]); if (connected.isDone()) break; } } async function lookupAddresses(hostname: string): Promise { // Use separate family lookups to avoid AI_ADDRCONFIG filtering. When family: 0 is used, // Node.js passes AI_ADDRCONFIG to getaddrinfo(), which on macOS can filter out addresses // for a family that has no non-loopback interface — e.g. returning only 127.0.0.1 for // "localhost" when IPv6 is not available on non-loopback interfaces, even though ::1 is // present in /etc/hosts. Separate family: 4 and family: 6 lookups do not pass AI_ADDRCONFIG. const [v4Result, v6Result] = await Promise.allSettled([ dns.promises.lookup(hostname, { all: true, family: 4 }), dns.promises.lookup(hostname, { all: true, family: 6 }), ]); const v4Addresses = v4Result.status === 'fulfilled' ? v4Result.value : []; const v6Addresses = v6Result.status === 'fulfilled' ? v6Result.value : []; if (!v4Addresses.length && !v6Addresses.length) { if (v4Result.status === 'rejected') throw v4Result.reason; throw (v6Result as PromiseRejectedResult).reason; } const result: dns.LookupAddress[] = []; // Alternate IPv6 and IPv4 addresses per RFC 8305 (prefer IPv6 first). for (let i = 0; i < Math.max(v4Addresses.length, v6Addresses.length); i++) { if (v6Addresses[i]) result.push(v6Addresses[i]); if (v4Addresses[i]) result.push(v4Addresses[i]); } return result; } function clientRequestArgsToHostName(options: http.ClientRequestArgs): string { if (options.hostname) return options.hostname; if (options.host) return options.host; throw new Error('Either options.hostname or options.host must be provided'); } export function timingForSocket(socket: net.Socket | tls.TLSSocket) { return { dnsLookupAt: (socket as any)[kDNSLookupAt] as number | undefined, tcpConnectionAt: (socket as any)[kTCPConnectionAt] as number | undefined, }; }