Skip to content

Commit be35f23

Browse files
author
Michael Solomon
authored
fix(recorder): recompress fetch body on record (#2810)
1 parent a143911 commit be35f23

File tree

2 files changed

+211
-105
lines changed

2 files changed

+211
-105
lines changed

lib/recorder.js

+131-105
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,18 @@ const { inspect } = require('util')
66

77
const common = require('./common')
88
const { restoreOverriddenClientRequest } = require('./intercept')
9-
const { BatchInterceptor } = require('@mswjs/interceptors')
9+
const { EventEmitter } = require('stream')
10+
const { gzipSync, brotliCompressSync, deflateSync } = require('zlib')
1011
const {
1112
default: nodeInterceptors,
1213
} = require('@mswjs/interceptors/presets/node')
13-
const { EventEmitter } = require('stream')
14-
1514
const SEPARATOR = '\n<<<<<<-- cut here -->>>>>>\n'
1615
let recordingInProgress = false
1716
let outputs = []
1817

19-
// TODO: Consider use one BatchInterceptor (and not one for intercept and one for record)
20-
const interceptor = new BatchInterceptor({
21-
name: 'nock-interceptor',
22-
interceptors: nodeInterceptors,
23-
})
18+
// TODO: don't reuse the nodeInterceptors, create new ones.
19+
const clientRequestInterceptor = nodeInterceptors[0]
20+
const fetchRequestInterceptor = nodeInterceptors[2]
2421

2522
function getScope(options) {
2623
const { proto, host, port } = common.normalizeRequestOptions(options)
@@ -226,109 +223,137 @@ function record(recOptions) {
226223
restoreOverriddenClientRequest()
227224

228225
// We override the requests so that we can save information on them before executing.
229-
interceptor.apply()
230-
interceptor.on(
231-
'request',
232-
async function ({ request: mswRequest, requestId }) {
233-
const request = mswRequest.clone()
234-
const { options } = common.normalizeClientRequestArgs(request.url)
235-
options.method = request.method
236-
const proto = options.protocol.slice(0, -1)
237-
238-
// Node 0.11 https.request calls http.request -- don't want to record things
239-
// twice.
240-
/* istanbul ignore if */
241-
if (options._recording) {
242-
return
243-
}
244-
options._recording = true
245-
246-
const req = new EventEmitter()
247-
req.on('response', function () {
248-
debug(thisRecordingId, 'intercepting', proto, 'request to record')
249-
250-
// Intercept "res.once('end', ...)"-like event
251-
interceptor.once(
252-
'response',
253-
async function ({ response: mswResponse }) {
254-
const response = mswResponse.clone()
255-
debug(thisRecordingId, proto, 'intercepted request ended')
256-
257-
let reqheaders
258-
// Ignore request headers completely unless it was explicitly enabled by the user (see README)
259-
if (enableReqHeadersRecording) {
260-
// We never record user-agent headers as they are worse than useless -
261-
// they actually make testing more difficult without providing any benefit (see README)
262-
reqheaders = Object.fromEntries(request.headers.entries())
263-
common.deleteHeadersField(reqheaders, 'user-agent')
264-
}
226+
clientRequestInterceptor.apply()
227+
fetchRequestInterceptor.apply()
228+
clientRequestInterceptor.on('request', async function ({ request }) {
229+
await recordRequest(request)
230+
})
231+
fetchRequestInterceptor.on('request', async function ({ request }) {
232+
await recordRequest(request)
233+
})
265234

266-
const headers = Object.fromEntries(response.headers.entries())
267-
const res = {
268-
statusCode: response.status,
269-
headers,
270-
rawHeaders: headers,
271-
}
235+
async function recordRequest(mswRequest) {
236+
const request = mswRequest.clone()
237+
const { options } = common.normalizeClientRequestArgs(request.url)
238+
options.method = request.method
239+
const proto = options.protocol.slice(0, -1)
240+
241+
// Node 0.11 https.request calls http.request -- don't want to record things
242+
// twice.
243+
/* istanbul ignore if */
244+
if (options._recording) {
245+
return
246+
}
247+
options._recording = true
272248

273-
const generateFn = outputObjects
274-
? generateRequestAndResponseObject
275-
: generateRequestAndResponse
276-
let out = generateFn({
277-
req: options,
278-
bodyChunks: [Buffer.from(await request.arrayBuffer())],
279-
options,
280-
res,
281-
dataChunks: [Buffer.from(await response.arrayBuffer())],
282-
reqheaders,
283-
})
284-
285-
debug('out:', out)
286-
287-
// Check that the request was made during the current recording.
288-
// If it hasn't then skip it. There is no other simple way to handle
289-
// this as it depends on the timing of requests and responses. Throwing
290-
// will make some recordings/unit tests fail randomly depending on how
291-
// fast/slow the response arrived.
292-
// If you are seeing this error then you need to make sure that all
293-
// the requests made during a single recording session finish before
294-
// ending the same recording session.
295-
if (thisRecordingId !== currentRecordingId) {
296-
debug('skipping recording of an out-of-order request', out)
297-
return
298-
}
249+
const req = new EventEmitter()
250+
req.on('response', function () {
251+
debug(thisRecordingId, 'intercepting', proto, 'request to record')
299252

300-
outputs.push(out)
301-
302-
if (!dontPrint) {
303-
if (useSeparator) {
304-
if (typeof out !== 'string') {
305-
out = JSON.stringify(out, null, 2)
306-
}
307-
logging(SEPARATOR + out + SEPARATOR)
308-
} else {
309-
logging(out)
310-
}
311-
}
312-
},
313-
)
253+
clientRequestInterceptor.once('response', async function ({ response }) {
254+
await recordResponse(response)
255+
})
256+
fetchRequestInterceptor.once('response', async function ({ response }) {
257+
// fetch decompresses the body automatically, so we need to recompress it
258+
const codings =
259+
response.headers
260+
.get('content-encoding')
261+
?.toLowerCase()
262+
.split(',')
263+
.map(c => c.trim()) || []
264+
265+
let body = await response.arrayBuffer()
266+
for (const coding of codings) {
267+
if (coding === 'gzip') {
268+
body = gzipSync(body)
269+
} else if (coding === 'deflate') {
270+
body = deflateSync(body)
271+
} else if (coding === 'br') {
272+
body = brotliCompressSync(body)
273+
}
274+
}
314275

315-
debug('finished setting up intercepting')
276+
await recordResponse(new Response(body, response))
277+
})
316278

317-
// We override both the http and the https modules; when we are
318-
// serializing the request, we need to know which was called.
319-
// By stuffing the state, we can make sure that nock records
320-
// the intended protocol.
321-
if (proto === 'https') {
322-
options.proto = 'https'
279+
// Intercept "res.once('end', ...)"-like event
280+
async function recordResponse(mswResponse) {
281+
const response = mswResponse.clone()
282+
debug(thisRecordingId, proto, 'intercepted request ended')
283+
284+
let reqheaders
285+
// Ignore request headers completely unless it was explicitly enabled by the user (see README)
286+
if (enableReqHeadersRecording) {
287+
// We never record user-agent headers as they are worse than useless -
288+
// they actually make testing more difficult without providing any benefit (see README)
289+
reqheaders = Object.fromEntries(request.headers.entries())
290+
common.deleteHeadersField(reqheaders, 'user-agent')
323291
}
324-
})
325292

326-
// This is a massive change, we are trying to change minimum code, so we emit end event here
327-
// because mswjs take care for these events
328-
// TODO: refactor the recorder, we no longer need all the listeners and can just record the request we get from MSW
329-
req.emit('response')
330-
},
331-
)
293+
const headers = Object.fromEntries(response.headers.entries())
294+
const res = {
295+
statusCode: response.status,
296+
headers,
297+
rawHeaders: headers,
298+
}
299+
300+
const generateFn = outputObjects
301+
? generateRequestAndResponseObject
302+
: generateRequestAndResponse
303+
let out = generateFn({
304+
req: options,
305+
bodyChunks: [Buffer.from(await request.arrayBuffer())],
306+
options,
307+
res,
308+
dataChunks: [Buffer.from(await response.arrayBuffer())],
309+
reqheaders,
310+
})
311+
312+
debug('out:', out)
313+
314+
// Check that the request was made during the current recording.
315+
// If it hasn't then skip it. There is no other simple way to handle
316+
// this as it depends on the timing of requests and responses. Throwing
317+
// will make some recordings/unit tests fail randomly depending on how
318+
// fast/slow the response arrived.
319+
// If you are seeing this error then you need to make sure that all
320+
// the requests made during a single recording session finish before
321+
// ending the same recording session.
322+
if (thisRecordingId !== currentRecordingId) {
323+
debug('skipping recording of an out-of-order request', out)
324+
return
325+
}
326+
327+
outputs.push(out)
328+
329+
if (!dontPrint) {
330+
if (useSeparator) {
331+
if (typeof out !== 'string') {
332+
out = JSON.stringify(out, null, 2)
333+
}
334+
logging(SEPARATOR + out + SEPARATOR)
335+
} else {
336+
logging(out)
337+
}
338+
}
339+
}
340+
341+
debug('finished setting up intercepting')
342+
343+
// We override both the http and the https modules; when we are
344+
// serializing the request, we need to know which was called.
345+
// By stuffing the state, we can make sure that nock records
346+
// the intended protocol.
347+
if (proto === 'https') {
348+
options.proto = 'https'
349+
}
350+
})
351+
352+
// This is a massive change, we are trying to change minimum code, so we emit end event here
353+
// because mswjs take care for these events
354+
// TODO: refactor the recorder, we no longer need all the listeners and can just record the request we get from MSW
355+
req.emit('response')
356+
}
332357
}
333358

334359
// Restore *all* the overridden http/https modules' properties.
@@ -338,7 +363,8 @@ function restore() {
338363
'restoring all the overridden http/https properties',
339364
)
340365

341-
interceptor.dispose()
366+
clientRequestInterceptor.dispose()
367+
fetchRequestInterceptor.dispose()
342368
restoreOverriddenClientRequest()
343369
recordingInProgress = false
344370
}

tests/test_fetch.js

+80
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,84 @@ describe('Native Fetch', () => {
509509
expect(body).to.be.empty()
510510
})
511511
})
512+
513+
describe('recording', () => {
514+
it('records and replays gzipped nocks correctly', async () => {
515+
const exampleText = '<html><body>example</body></html>'
516+
517+
const { origin } = await startHttpServer((request, response) => {
518+
// TODO: flip the order of the encoding, this is a bug in fetch
519+
// const body = zlib.brotliCompressSync(zlib.gzipSync(exampleText))
520+
const body = zlib.gzipSync(zlib.brotliCompressSync(exampleText))
521+
522+
response.writeHead(200, { 'content-encoding': 'gzip, br' })
523+
response.end(body)
524+
})
525+
526+
nock.restore()
527+
nock.recorder.clear()
528+
expect(nock.recorder.play()).to.be.empty()
529+
530+
nock.recorder.rec({
531+
dont_print: true,
532+
output_objects: true,
533+
})
534+
535+
const response1 = await fetch(origin)
536+
expect(await response1.text()).to.equal(exampleText)
537+
expect(response1.headers.get('content-encoding')).to.equal('gzip, br')
538+
539+
nock.restore()
540+
const recorded = nock.recorder.play()
541+
nock.recorder.clear()
542+
nock.activate()
543+
544+
expect(recorded).to.have.lengthOf(1)
545+
const nocks = nock.define(recorded)
546+
547+
const response2 = await fetch(origin)
548+
expect(await response2.text()).to.equal(exampleText)
549+
expect(response1.headers.get('content-encoding')).to.equal('gzip, br')
550+
551+
nocks.forEach(nock => nock.done())
552+
})
553+
554+
it('records and replays deflated nocks correctly', async () => {
555+
const exampleText = '<html><body>example</body></html>'
556+
557+
const { origin } = await startHttpServer((request, response) => {
558+
const body = zlib.deflateSync(exampleText)
559+
560+
response.writeHead(200, { 'content-encoding': 'deflate' })
561+
response.end(body)
562+
})
563+
564+
nock.restore()
565+
nock.recorder.clear()
566+
expect(nock.recorder.play()).to.be.empty()
567+
568+
nock.recorder.rec({
569+
dont_print: true,
570+
output_objects: true,
571+
})
572+
573+
const response1 = await fetch(origin)
574+
expect(await response1.text()).to.equal(exampleText)
575+
expect(response1.headers.get('content-encoding')).to.equal('deflate')
576+
577+
nock.restore()
578+
const recorded = nock.recorder.play()
579+
nock.recorder.clear()
580+
nock.activate()
581+
582+
expect(recorded).to.have.lengthOf(1)
583+
const nocks = nock.define(recorded)
584+
585+
const response2 = await fetch(origin)
586+
expect(await response2.text()).to.equal(exampleText)
587+
expect(response1.headers.get('content-encoding')).to.equal('deflate')
588+
589+
nocks.forEach(nock => nock.done())
590+
})
591+
})
512592
})

0 commit comments

Comments
 (0)