/** * 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 http from 'http'; import http2 from 'http2'; import https from 'https'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent'; import { getProxyForUrl } from 'proxy-from-env'; import { ManualPromise } from '@isomorphic/manualPromise'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs'; import type net from 'net'; export type ProxySettings = { server: string, bypass?: string, username?: string, password?: string }; export type HTTPRequestParams = { url: string, method?: string, headers?: http.OutgoingHttpHeaders, data?: string | Buffer, rejectUnauthorized?: boolean, socketTimeout?: number, }; export const NET_DEFAULT_TIMEOUT = 30_000; export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void): { cancel(error: Error | undefined): void } { let url = new URL(params.url); const options: https.RequestOptions = { method: params.method || 'GET', headers: params.headers, }; if (params.rejectUnauthorized !== undefined) options.rejectUnauthorized = params.rejectUnauthorized; const proxyURL = getProxyForUrl(params.url); if (proxyURL) { const parsedProxyURL = normalizeProxyURL(proxyURL); if (params.url.startsWith('http:')) { options.path = url.toString(); url = parsedProxyURL; } else { options.agent = new HttpsProxyAgent(parsedProxyURL); } } options.agent ??= url.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent; let cancelRequest: (e: Error | undefined) => void; const requestCallback = (res: http.IncomingMessage) => { const statusCode = res.statusCode || 0; if (statusCode >= 300 && statusCode < 400 && res.headers.location) { // Close the original socket before following the redirect. Otherwise // it may stay idle and cause a timeout error. request.destroy(); cancelRequest = httpRequest({ ...params, url: new URL(res.headers.location, params.url).toString() }, onResponse, onError).cancel; } else { onResponse(res); } }; const request = url.protocol === 'https:' ? https.request(url, options, requestCallback) : http.request(url, options, requestCallback); request.on('error', onError); if (params.socketTimeout !== undefined) { request.setTimeout(params.socketTimeout, () => { onError(new Error(`Request to ${params.url} timed out after ${params.socketTimeout}ms`)); request.abort(); }); } cancelRequest = e => { try { request.destroy(e); } catch { } }; request.end(params.data); return { cancel: e => cancelRequest(e) }; } function shouldBypassProxy(url: URL, bypass?: string): boolean { if (!bypass) return false; const domains = bypass.split(',').map(s => { s = s.trim(); if (!s.startsWith('.')) s = '.' + s; return s; }); const domain = '.' + url.hostname; return domains.some(d => domain.endsWith(d)); } function normalizeProxyURL(proxy: string): URL { proxy = proxy.trim(); // Browsers allow to specify proxy without a protocol, defaulting to http. if (!/^\w+:\/\//.test(proxy)) proxy = 'http://' + proxy; return new URL(proxy); } export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { if (!proxy) return; if (forUrl && proxy.bypass && shouldBypassProxy(forUrl, proxy.bypass)) return; const proxyURL = normalizeProxyURL(proxy.server); if (proxyURL.protocol?.startsWith('socks')) { // SocksProxyAgent distinguishes between socks5 and socks5h. // socks5h is what we want, it means that hostnames are resolved by the proxy. // browsers behave the same way, even if socks5 is specified. if (proxyURL.protocol === 'socks5:') proxyURL.protocol = 'socks5h:'; else if (proxyURL.protocol === 'socks4:') proxyURL.protocol = 'socks4a:'; return new SocksProxyAgent(proxyURL); } if (proxy.username) { proxyURL.username = proxy.username; proxyURL.password = proxy.password || ''; } if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) { // Force CONNECT method for WebSockets. return new HttpsProxyAgent(proxyURL); } // TODO: This branch should be different from above. We should use HttpProxyAgent conditional on proxyURL.protocol instead of always using CONNECT method. return new HttpsProxyAgent(proxyURL); } export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(...args: any[]): http.Server { const server = http.createServer(...args); decorateServer(server); return server; } export function createHttpsServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): https.Server; export function createHttpsServer(options: https.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): https.Server; export function createHttpsServer(...args: any[]): https.Server { const server = https.createServer(...args); decorateServer(server); return server; } export function createHttp2Server(onRequestHandler?: (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void,): http2.Http2SecureServer; export function createHttp2Server(options: http2.SecureServerOptions, onRequestHandler?: (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void,): http2.Http2SecureServer; export function createHttp2Server(...args: any[]): http2.Http2SecureServer { const server = http2.createSecureServer(...args); decorateServer(server); return server; } export async function startHttpServer(server: http.Server, options: { host?: string, port?: number }) { const { host = 'localhost', port = 0 } = options; const errorPromise = new ManualPromise(); const errorListener = (error: Error) => errorPromise.reject(error); server.on('error', errorListener); try { server.listen(port, host); await Promise.race([ new Promise(cb => server.once('listening', cb)), errorPromise, ]); } finally { server.removeListener('error', errorListener); } } export async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void) { let statusCode = await httpStatusCode(url, ignoreHTTPSErrors, onLog, onStdErr); if (statusCode === 404 && url.pathname === '/') { const indexUrl = new URL(url); indexUrl.pathname = '/index.html'; statusCode = await httpStatusCode(indexUrl, ignoreHTTPSErrors, onLog, onStdErr); } return statusCode >= 200 && statusCode < 404; } async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void): Promise { return new Promise(resolve => { onLog?.(`HTTP GET: ${url}`); httpRequest({ url: url.toString(), headers: { Accept: '*/*' }, rejectUnauthorized: !ignoreHTTPSErrors }, res => { res.resume(); const statusCode = res.statusCode ?? 0; onLog?.(`HTTP Status: ${statusCode}`); resolve(statusCode); }, error => { if ((error as NodeJS.ErrnoException).code === 'DEPTH_ZERO_SELF_SIGNED_CERT') onStdErr?.(`[WebServer] Self-signed certificate detected. Try adding ignoreHTTPSErrors: true to config.webServer.`); onLog?.(`Error while checking if ${url} is available: ${error.message}`); resolve(0); }); }); } export function decorateServer(server: net.Server) { const sockets = new Set(); server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); }); const close = server.close; server.close = (callback?: (err?: Error) => void) => { for (const socket of sockets) socket.destroy(); sockets.clear(); return close.call(server, callback); }; }