UNPKG

11.1 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('nock.request_overrider')
4const {
5 IncomingMessage,
6 ClientRequest,
7 request: originalHttpRequest,
8} = require('http')
9const { request: originalHttpsRequest } = require('https')
10const propagate = require('propagate')
11const common = require('./common')
12const globalEmitter = require('./global_emitter')
13const Socket = require('./socket')
14const { playbackInterceptor } = require('./playback_interceptor')
15
16function socketOnClose(req) {
17 debug('socket close')
18
19 if (!req.res && !req.socket._hadError) {
20 // If we don't have a response then we know that the socket
21 // ended prematurely and we need to emit an error on the request.
22 req.socket._hadError = true
23 const err = new Error('socket hang up')
24 err.code = 'ECONNRESET'
25 req.emit('error', err)
26 }
27 req.emit('close')
28}
29
30/**
31 * Given a group of interceptors, appropriately route an outgoing request.
32 * Identify which interceptor ought to respond, if any, then delegate to
33 * `playbackInterceptor()` to consume the request itself.
34 */
35class InterceptedRequestRouter {
36 constructor({ req, options, interceptors }) {
37 this.req = req
38 this.options = {
39 // We may be changing the options object and we don't want those changes
40 // affecting the user so we use a clone of the object.
41 ...options,
42 // We use lower-case header field names throughout Nock.
43 headers: common.headersFieldNamesToLowerCase(options.headers || {}),
44 }
45 this.interceptors = interceptors
46
47 this.socket = new Socket(options)
48
49 // support setting `timeout` using request `options`
50 // https://nodejs.org/docs/latest-v12.x/api/http.html#http_http_request_url_options_callback
51 if (options.timeout) {
52 this.socket.setTimeout(options.timeout)
53 }
54
55 this.response = new IncomingMessage(this.socket)
56 this.requestBodyBuffers = []
57 this.playbackStarted = false
58
59 // For parity with Node, it's important the socket event is emitted before we begin playback.
60 // This flag is set when playback is triggered if we haven't yet gotten the
61 // socket event to indicate that playback should start as soon as it comes in.
62 this.readyToStartPlaybackOnSocketEvent = false
63
64 this.attachToReq()
65
66 // Emit a fake socket event on the next tick to mimic what would happen on a real request.
67 // Some clients listen for a 'socket' event to be emitted before calling end(),
68 // which causes Nock to hang.
69 process.nextTick(() => this.connectSocket())
70 }
71
72 attachToReq() {
73 const { req, options } = this
74
75 for (const [name, val] of Object.entries(options.headers)) {
76 req.setHeader(name.toLowerCase(), val)
77 }
78
79 if (options.auth && !options.headers.authorization) {
80 req.setHeader(
81 // We use lower-case header field names throughout Nock.
82 'authorization',
83 `Basic ${Buffer.from(options.auth).toString('base64')}`
84 )
85 }
86
87 req.path = options.path
88 req.method = options.method
89
90 req.write = (...args) => this.handleWrite(...args)
91 req.end = (...args) => this.handleEnd(...args)
92 req.flushHeaders = (...args) => this.handleFlushHeaders(...args)
93
94 // https://github.com/nock/nock/issues/256
95 if (options.headers.expect === '100-continue') {
96 common.setImmediate(() => {
97 debug('continue')
98 req.emit('continue')
99 })
100 }
101 }
102
103 connectSocket() {
104 const { req, socket } = this
105
106 // Until Node 14 is the minimum, we need to look at both flags to see if the request has been cancelled.
107 if (req.destroyed || req.aborted) {
108 return
109 }
110
111 // ClientRequest.connection is an alias for ClientRequest.socket
112 // https://nodejs.org/api/http.html#http_request_socket
113 // https://github.com/nodejs/node/blob/b0f75818f39ed4e6bd80eb7c4010c1daf5823ef7/lib/_http_client.js#L640-L641
114 // The same Socket is shared between the request and response to mimic native behavior.
115 req.socket = req.connection = socket
116
117 propagate(['error', 'timeout'], socket, req)
118 socket.on('close', () => socketOnClose(req))
119
120 socket.connecting = false
121 req.emit('socket', socket)
122
123 // https://nodejs.org/api/net.html#net_event_connect
124 socket.emit('connect')
125
126 // https://nodejs.org/api/tls.html#tls_event_secureconnect
127 if (socket.authorized) {
128 socket.emit('secureConnect')
129 }
130
131 if (this.readyToStartPlaybackOnSocketEvent) {
132 this.maybeStartPlayback()
133 }
134 }
135
136 // from docs: When write function is called with empty string or buffer, it does nothing and waits for more input.
137 // However, actually implementation checks the state of finished and aborted before checking if the first arg is empty.
138 handleWrite(buffer, encoding, callback) {
139 debug('request write')
140 const { req } = this
141
142 if (req.finished) {
143 const err = new Error('write after end')
144 err.code = 'ERR_STREAM_WRITE_AFTER_END'
145 process.nextTick(() => req.emit('error', err))
146
147 // It seems odd to return `true` here, not sure why you'd want to have
148 // the stream potentially written to more, but it's what Node does.
149 // https://github.com/nodejs/node/blob/a9270dcbeba4316b1e179b77ecb6c46af5aa8c20/lib/_http_outgoing.js#L662-L665
150 return true
151 }
152
153 if (req.socket && req.socket.destroyed) {
154 return false
155 }
156
157 if (!buffer || buffer.length === 0) {
158 return true
159 }
160
161 if (!Buffer.isBuffer(buffer)) {
162 buffer = Buffer.from(buffer, encoding)
163 }
164 this.requestBodyBuffers.push(buffer)
165
166 // can't use instanceof Function because some test runners
167 // run tests in vm.runInNewContext where Function is not same
168 // as that in the current context
169 // https://github.com/nock/nock/pull/1754#issuecomment-571531407
170 if (typeof callback === 'function') {
171 callback()
172 }
173
174 common.setImmediate(function () {
175 req.emit('drain')
176 })
177
178 return false
179 }
180
181 handleEnd(chunk, encoding, callback) {
182 debug('request end')
183 const { req } = this
184
185 // handle the different overloaded arg signatures
186 if (typeof chunk === 'function') {
187 callback = chunk
188 chunk = null
189 } else if (typeof encoding === 'function') {
190 callback = encoding
191 encoding = null
192 }
193
194 if (typeof callback === 'function') {
195 req.once('finish', callback)
196 }
197
198 if (chunk) {
199 req.write(chunk, encoding)
200 }
201 req.finished = true
202 this.maybeStartPlayback()
203
204 return req
205 }
206
207 handleFlushHeaders() {
208 debug('request flushHeaders')
209 this.maybeStartPlayback()
210 }
211
212 /**
213 * Set request headers of the given request. This is needed both during the
214 * routing phase, in case header filters were specified, and during the
215 * interceptor-playback phase, to correctly pass mocked request headers.
216 * TODO There are some problems with this; see https://github.com/nock/nock/issues/1718
217 */
218 setHostHeaderUsingInterceptor(interceptor) {
219 const { req, options } = this
220
221 // If a filtered scope is being used we have to use scope's host in the
222 // header, otherwise 'host' header won't match.
223 // NOTE: We use lower-case header field names throughout Nock.
224 const HOST_HEADER = 'host'
225 if (interceptor.__nock_filteredScope && interceptor.__nock_scopeHost) {
226 options.headers[HOST_HEADER] = interceptor.__nock_scopeHost
227 req.setHeader(HOST_HEADER, interceptor.__nock_scopeHost)
228 } else {
229 // For all other cases, we always add host header equal to the requested
230 // host unless it was already defined.
231 if (options.host && !req.getHeader(HOST_HEADER)) {
232 let hostHeader = options.host
233
234 if (options.port === 80 || options.port === 443) {
235 hostHeader = hostHeader.split(':')[0]
236 }
237
238 req.setHeader(HOST_HEADER, hostHeader)
239 }
240 }
241 }
242
243 maybeStartPlayback() {
244 const { req, socket, playbackStarted } = this
245
246 // In order to get the events in the right order we need to delay playback
247 // if we get here before the `socket` event is emitted.
248 if (socket.connecting) {
249 this.readyToStartPlaybackOnSocketEvent = true
250 return
251 }
252
253 // Until Node 14 is the minimum, we need to look at both flags to see if the request has been cancelled.
254 if (!req.destroyed && !req.aborted && !playbackStarted) {
255 this.startPlayback()
256 }
257 }
258
259 startPlayback() {
260 debug('ending')
261 this.playbackStarted = true
262
263 const { req, response, socket, options, interceptors } = this
264
265 Object.assign(options, {
266 // Re-update `options` with the current value of `req.path` because badly
267 // behaving agents like superagent like to change `req.path` mid-flight.
268 path: req.path,
269 // Similarly, node-http-proxy will modify headers in flight, so we have
270 // to put the headers back into options.
271 // https://github.com/nock/nock/pull/1484
272 headers: req.getHeaders(),
273 // Fixes https://github.com/nock/nock/issues/976
274 protocol: `${options.proto}:`,
275 })
276
277 interceptors.forEach(interceptor => {
278 this.setHostHeaderUsingInterceptor(interceptor)
279 })
280
281 const requestBodyBuffer = Buffer.concat(this.requestBodyBuffers)
282 // When request body is a binary buffer we internally use in its hexadecimal
283 // representation.
284 const requestBodyIsUtf8Representable = common.isUtf8Representable(
285 requestBodyBuffer
286 )
287 const requestBodyString = requestBodyBuffer.toString(
288 requestBodyIsUtf8Representable ? 'utf8' : 'hex'
289 )
290
291 const matchedInterceptor = interceptors.find(i =>
292 i.match(req, options, requestBodyString)
293 )
294
295 if (matchedInterceptor) {
296 matchedInterceptor.scope.logger(
297 'interceptor identified, starting mocking'
298 )
299
300 matchedInterceptor.markConsumed()
301
302 // wait to emit the finish event until we know for sure an Interceptor is going to playback.
303 // otherwise an unmocked request might emit finish twice.
304 req.emit('finish')
305
306 playbackInterceptor({
307 req,
308 socket,
309 options,
310 requestBodyString,
311 requestBodyIsUtf8Representable,
312 response,
313 interceptor: matchedInterceptor,
314 })
315 } else {
316 globalEmitter.emit('no match', req, options, requestBodyString)
317
318 // Try to find a hostname match that allows unmocked.
319 const allowUnmocked = interceptors.some(
320 i => i.matchHostName(options) && i.options.allowUnmocked
321 )
322
323 if (allowUnmocked && req instanceof ClientRequest) {
324 const newReq =
325 options.proto === 'https'
326 ? originalHttpsRequest(options)
327 : originalHttpRequest(options)
328
329 propagate(newReq, req)
330 // We send the raw buffer as we received it, not as we interpreted it.
331 newReq.end(requestBodyBuffer)
332 } else {
333 const reqStr = common.stringifyRequest(options, requestBodyString)
334 const err = new Error(`Nock: No match for request ${reqStr}`)
335 err.code = 'ERR_NOCK_NO_MATCH'
336 err.statusCode = err.status = 404
337 req.destroy(err)
338 }
339 }
340 }
341}
342
343module.exports = { InterceptedRequestRouter }