UNPKG

10 kBJavaScriptView Raw
1// Licensed to the Software Freedom Conservancy (SFC) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The SFC licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18/**
19 * @fileoverview Defines an {@linkplain cmd.Executor command executor} that
20 * communicates with a remote end using HTTP + JSON.
21 */
22
23'use strict'
24
25const http = require('http')
26const https = require('https')
27const url = require('url')
28
29const httpLib = require('../lib/http')
30
31/**
32 * @typedef {{protocol: (?string|undefined),
33 * auth: (?string|undefined),
34 * hostname: (?string|undefined),
35 * host: (?string|undefined),
36 * port: (?string|undefined),
37 * path: (?string|undefined),
38 * pathname: (?string|undefined)}}
39 */
40var RequestOptions // eslint-disable-line
41
42/**
43 * @param {string} aUrl The request URL to parse.
44 * @return {RequestOptions} The request options.
45 * @throws {Error} if the URL does not include a hostname.
46 */
47function getRequestOptions(aUrl) {
48 //eslint-disable-next-line node/no-deprecated-api
49 let options = url.parse(aUrl)
50 if (!options.hostname) {
51 throw new Error('Invalid URL: ' + aUrl)
52 }
53 // Delete the search and has portions as they are not used.
54 options.search = null
55 options.hash = null
56 options.path = options.pathname
57 return options
58}
59
60/** @const {string} */
61const USER_AGENT = (function () {
62 const version = require('../package.json').version
63 const platform =
64 { darwin: 'mac', win32: 'windows' }[process.platform] || 'linux'
65 return `selenium/${version} (js ${platform})`
66})()
67
68/**
69 * A basic HTTP client used to send messages to a remote end.
70 *
71 * @implements {httpLib.Client}
72 */
73class HttpClient {
74 /**
75 * @param {string} serverUrl URL for the WebDriver server to send commands to.
76 * @param {http.Agent=} opt_agent The agent to use for each request.
77 * Defaults to `http.globalAgent`.
78 * @param {?string=} opt_proxy The proxy to use for the connection to the
79 * server. Default is to use no proxy.
80 * @param {?Object.<string,Object>} client_options
81 */
82 constructor(serverUrl, opt_agent, opt_proxy, client_options = {}) {
83 /** @private {http.Agent} */
84 this.agent_ = opt_agent || null
85
86 /**
87 * Base options for each request.
88 * @private {RequestOptions}
89 */
90 this.options_ = getRequestOptions(serverUrl)
91
92 /**
93 * client options, header overrides
94 */
95 this.client_options = client_options
96
97 /**
98 * sets keep-alive for the agent
99 * see https://stackoverflow.com/a/58332910
100 */
101 this.keepAlive = this.client_options['keep-alive']
102
103 /**
104 * @private {?RequestOptions}
105 */
106 this.proxyOptions_ = opt_proxy ? getRequestOptions(opt_proxy) : null
107 }
108
109 get keepAlive() {
110 return this.agent_.keepAlive
111 }
112
113 set keepAlive(value) {
114 if (value === 'true' || value === true) {
115 this.agent_.keepAlive = true
116 }
117 }
118
119 /** @override */
120 send(httpRequest) {
121 let data
122
123 let headers = {}
124
125 if (httpRequest.headers) {
126 httpRequest.headers.forEach(function (value, name) {
127 headers[name] = value
128 })
129 }
130
131 headers['User-Agent'] = this.client_options['user-agent'] || USER_AGENT
132 headers['Content-Length'] = 0
133 if (httpRequest.method == 'POST' || httpRequest.method == 'PUT') {
134 data = JSON.stringify(httpRequest.data)
135 headers['Content-Length'] = Buffer.byteLength(data, 'utf8')
136 headers['Content-Type'] = 'application/json;charset=UTF-8'
137 }
138
139 let path = this.options_.path
140 if (path.endsWith('/') && httpRequest.path.startsWith('/')) {
141 path += httpRequest.path.substring(1)
142 } else {
143 path += httpRequest.path
144 }
145 //eslint-disable-next-line node/no-deprecated-api
146 let parsedPath = url.parse(path)
147
148 let options = {
149 agent: this.agent_ || null,
150 method: httpRequest.method,
151
152 auth: this.options_.auth,
153 hostname: this.options_.hostname,
154 port: this.options_.port,
155 protocol: this.options_.protocol,
156
157 path: parsedPath.path,
158 pathname: parsedPath.pathname,
159 search: parsedPath.search,
160 hash: parsedPath.hash,
161
162 headers,
163 }
164
165 return new Promise((fulfill, reject) => {
166 sendRequest(options, fulfill, reject, data, this.proxyOptions_)
167 })
168 }
169}
170
171/**
172 * Sends a single HTTP request.
173 * @param {!Object} options The request options.
174 * @param {function(!httpLib.Response)} onOk The function to call if the
175 * request succeeds.
176 * @param {function(!Error)} onError The function to call if the request fails.
177 * @param {?string=} opt_data The data to send with the request.
178 * @param {?RequestOptions=} opt_proxy The proxy server to use for the request.
179 * @param {number=} opt_retries The current number of retries.
180 */
181function sendRequest(options, onOk, onError, opt_data, opt_proxy, opt_retries) {
182 var hostname = options.hostname
183 var port = options.port
184
185 if (opt_proxy) {
186 let proxy = /** @type {RequestOptions} */ (opt_proxy)
187
188 // RFC 2616, section 5.1.2:
189 // The absoluteURI form is REQUIRED when the request is being made to a
190 // proxy.
191 let absoluteUri = url.format(options)
192
193 // RFC 2616, section 14.23:
194 // An HTTP/1.1 proxy MUST ensure that any request message it forwards does
195 // contain an appropriate Host header field that identifies the service
196 // being requested by the proxy.
197 let targetHost = options.hostname
198 if (options.port) {
199 targetHost += ':' + options.port
200 }
201
202 // Update the request options with our proxy info.
203 options.headers['Host'] = targetHost
204 options.path = absoluteUri
205 options.host = proxy.host
206 options.hostname = proxy.hostname
207 options.port = proxy.port
208
209 // Update the protocol to avoid EPROTO errors when the webdriver proxy
210 // uses a different protocol from the remote selenium server.
211 options.protocol = opt_proxy.protocol
212
213 if (proxy.auth) {
214 options.headers['Proxy-Authorization'] =
215 'Basic ' + Buffer.from(proxy.auth).toString('base64')
216 }
217 }
218
219 let requestFn = options.protocol === 'https:' ? https.request : http.request
220 var request = requestFn(options, function onResponse(response) {
221 if (response.statusCode == 302 || response.statusCode == 303) {
222 try {
223 // eslint-disable-next-line node/no-deprecated-api
224 var location = url.parse(response.headers['location'])
225 } catch (ex) {
226 onError(
227 Error(
228 'Failed to parse "Location" header for server redirect: ' +
229 ex.message +
230 '\nResponse was: \n' +
231 new httpLib.Response(response.statusCode, response.headers, '')
232 )
233 )
234 return
235 }
236
237 if (!location.hostname) {
238 location.hostname = hostname
239 location.port = port
240 location.auth = options.auth
241 }
242
243 request.abort()
244 sendRequest(
245 {
246 method: 'GET',
247 protocol: location.protocol || options.protocol,
248 hostname: location.hostname,
249 port: location.port,
250 path: location.path,
251 auth: location.auth,
252 pathname: location.pathname,
253 search: location.search,
254 hash: location.hash,
255 headers: {
256 Accept: 'application/json; charset=utf-8',
257 'User-Agent': options.headers['User-Agent'] || USER_AGENT,
258 },
259 },
260 onOk,
261 onError,
262 undefined,
263 opt_proxy
264 )
265 return
266 }
267
268 var body = []
269 response.on('data', body.push.bind(body))
270 response.on('end', function () {
271 var resp = new httpLib.Response(
272 /** @type {number} */ (response.statusCode),
273 /** @type {!Object<string>} */ (response.headers),
274 Buffer.concat(body).toString('utf8').replace(/\0/g, '')
275 )
276 onOk(resp)
277 })
278 })
279
280 request.on('error', function (e) {
281 if (typeof opt_retries === 'undefined') {
282 opt_retries = 0
283 }
284
285 if (shouldRetryRequest(opt_retries, e)) {
286 opt_retries += 1
287 setTimeout(function () {
288 sendRequest(options, onOk, onError, opt_data, opt_proxy, opt_retries)
289 }, 15)
290 } else {
291 var message = e.message
292 if (e.code) {
293 message = e.code + ' ' + message
294 }
295 onError(new Error(message))
296 }
297 })
298
299 if (opt_data) {
300 request.write(opt_data)
301 }
302
303 request.end()
304}
305
306const MAX_RETRIES = 3
307
308/**
309 * A retry is sometimes needed on Windows where we may quickly run out of
310 * ephemeral ports. A more robust solution is bumping the MaxUserPort setting
311 * as described here: http://msdn.microsoft.com/en-us/library/aa560610%28v=bts.20%29.aspx
312 *
313 * @param {!number} retries
314 * @param {!Error} err
315 * @return {boolean}
316 */
317function shouldRetryRequest(retries, err) {
318 return retries < MAX_RETRIES && isRetryableNetworkError(err)
319}
320
321/**
322 * @param {!Error} err
323 * @return {boolean}
324 */
325function isRetryableNetworkError(err) {
326 if (err && err.code) {
327 return (
328 err.code === 'ECONNABORTED' ||
329 err.code === 'ECONNRESET' ||
330 err.code === 'ECONNREFUSED' ||
331 err.code === 'EADDRINUSE' ||
332 err.code === 'EPIPE' ||
333 err.code === 'ETIMEDOUT'
334 )
335 }
336
337 return false
338}
339
340// PUBLIC API
341
342exports.Agent = http.Agent
343exports.Executor = httpLib.Executor
344exports.HttpClient = HttpClient
345exports.Request = httpLib.Request
346exports.Response = httpLib.Response