Repository: nock/nock
Pull request #2580 (2024-02-03 18:13:57)

feat: native fetch mocking
BREAKING CHANGE: drop support for Node < 18

Fetch support.

1. Solves the urgent need for fetch support.
2. Although I think it's working as expected, I believe we should consider this as experimental until we see it working properly.
3. Recording is not supported, yet(?). Workaround can be found [here](https://github.com/nock/nock/issues/2397#issuecomment-1924424258).
4. I believe that we should keep working on switching the 'backend' to use 'mswjs/interceptors'., [here is why](https://github.com/nock/nock/pull/2517#:~:text=edited-,Why%3F,-Decompose%20interception%20and).

@gr2m WDYT?
CC @kettanaito

Uzlopak: I restructured few weeks ago the test folder. The folder test/got contains all tests which are got specific. So theoretically, you could copy the folder and name it fetch and rewrite the tests from using got to fetch.
gr2m: > Fetch support

Outstanding!!

> 4. I believe that we should keep working on switching the 'backend' to use 'mswjs/interceptors'., [here is why](https://github.com/nock/nock/pull/2517#:~:text=edited-,Why%3F,-Decompose%20interception%20and).

Agree!

> 2. we should consider this as experimental

Yes! I'll setup beta releasing. Feel free to drop any support/tests for any Node version below 18
mikicho: @gr2m Thanks!
Also, the tests fail due to coverage issues.

1. In antic versions, fetch is not supported, so the tests skip on some lines (ofc)
2. and in more modern versions, it fails on an `if` statement which has no `false` case, which I'm not sure what I can do about
![image](https://github.com/nock/nock/assets/11459632/7658cade-5aa5-49ba-8cc8-cf33896fa0f4)

Please advise.
mikicho: @gr2m Thanks! It would also solve the coverage issues :)
 I think you need approve the pull request.

mikicho: @gr2m Thanks! It would also solve the coverage issues.
I think I need you to approve the pull request.

Patch:
diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml
index 630d758..74462c1 100644
--- a/.github/workflows/continuous-integration.yaml
+++ b/.github/workflows/continuous-integration.yaml
@@ -22,7 +22,7 @@ jobs:
       - name: Setup Node
         uses: actions/setup-node@v4
         with:
-          node-version: 16
+          node-version: 20
           cache: 'npm'
       - name: Install dependencies
         run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline
@@ -70,11 +70,9 @@ jobs:
       fail-fast: false
       matrix:
         node-version:
-          - 10
-          - 12
-          - 14
-          - 16
           - 18
+          - 20
+          - 21
         os:
           - macos-latest
           - ubuntu-latest
@@ -99,7 +97,6 @@ jobs:
         run: npm run test
       - name: Test jest
         run: npm run test:jest
-        if: matrix.node-version >= 14
 
   # separate job to set as required in branch protection,
   # as the build names above change each time Node versions change
diff --git a/lib/common.js b/lib/common.js
index 336bc4d..a1f20fa 100644
--- a/lib/common.js
+++ b/lib/common.js
@@ -735,6 +735,24 @@ const expand = input => {
   return result
 }
 
+/**
+ * @param {Request} request
+ */
+function convertFetchRequestToOptions(request) {
+  const url = new URL(request.url)
+  const options = {
+    ...urlToOptions(url),
+    method: request.method,
+    host: url.hostname,
+    port: url.port || (url.protocol === 'https:' ? 443 : 80),
+    path: url.pathname + url.search,
+    proto: url.protocol.slice(0, -1),
+    headers: Object.fromEntries(request.headers.entries()),
+  }
+
+  return options
+}
+
 module.exports = {
   contentEncoding,
   dataEqual,
@@ -765,4 +783,5 @@ module.exports = {
   setInterval,
   setTimeout,
   stringifyRequest,
+  convertFetchRequestToClientRequest: convertFetchRequestToOptions,
 }
diff --git a/lib/create_response.js b/lib/create_response.js
new file mode 100644
index 0000000..3884506
--- /dev/null
+++ b/lib/create_response.js
@@ -0,0 +1,45 @@
+'use strict'
+
+const { headersArrayToObject } = require('./common')
+
+/**
+ * Creates a Fetch API `Response` instance from the given
+ * `http.IncomingMessage` instance.
+ * Inspired by: https://github.com/mswjs/interceptors/blob/04152ed914f8041272b6e92ed374216b8177e1b2/src/interceptors/ClientRequest/utils/createResponse.ts#L8
+ */
+
+/**
+ * Response status codes for responses that cannot have body.
+ * @see https://fetch.spec.whatwg.org/#statuses
+ */
+const responseStatusCodesWithoutBody = [204, 205, 304]
+
+/**
+ * @param {IncomingMessage} message
+ */
+function createResponse(message) {
+  const responseBodyOrNull = responseStatusCodesWithoutBody.includes(
+    message.statusCode,
+  )
+    ? null
+    : new ReadableStream({
+        start(controller) {
+          message.on('data', chunk => controller.enqueue(chunk))
+          message.on('end', () => controller.close())
+
+          /**
+           * @todo Should also listen to the "error" on the message
+           * and forward it to the controller. Otherwise the stream
+           * will pend indefinitely.
+           */
+        },
+      })
+
+  return new Response(responseBodyOrNull, {
+    status: message.statusCode,
+    statusText: message.statusMessage,
+    headers: headersArrayToObject(message.rawHeaders),
+  })
+}
+
+module.exports = { createResponse }
diff --git a/lib/intercept.js b/lib/intercept.js
index cf266d5..4896f6f 100644
--- a/lib/intercept.js
+++ b/lib/intercept.js
@@ -10,6 +10,7 @@ const { inherits } = require('util')
 const http = require('http')
 const debug = require('debug')('nock.intercept')
 const globalEmitter = require('./global_emitter')
+const { createResponse } = require('./create_response')
 
 /**
  * @name NetConnectNotAllowedError
@@ -302,7 +303,9 @@ function overrideClientRequest() {
 
       //  Fallback to original ClientRequest if nock is off or the net connection is enabled.
       if (isOff() || isEnabledForNetConnect(options)) {
-        originalClientRequest.apply(this, arguments)
+        if (options.isFetchRequest === undefined) {
+          originalClientRequest.apply(this, arguments)
+        }
       } else {
         common.setImmediate(
           function () {
@@ -434,6 +437,28 @@ function activate() {
     }
   })
 
+  const originalFetch = global.fetch
+  global.fetch = async (input, init = undefined) => {
+    const request = new Request(input, init)
+    const options = common.convertFetchRequestToClientRequest(request)
+    options.isFetchRequest = true
+    const body = await request.arrayBuffer()
+    const clientRequest = new http.ClientRequest(options)
+
+    // If mock found
+    if (clientRequest.interceptors) {
+      return new Promise((resolve, reject) => {
+        clientRequest.on('response', response => {
+          resolve(createResponse(response))
+        })
+        clientRequest.on('error', reject)
+        clientRequest.end(body)
+      })
+    } else {
+      return originalFetch(input, init)
+    }
+  }
+
   overrideClientRequest()
 }
 


Test patch:
diff --git a/tests/test_fetch.js b/tests/test_fetch.js
new file mode 100644
index 0000000..fc091c1
--- /dev/null
+++ b/tests/test_fetch.js
@@ -0,0 +1,93 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('..')
+const assertRejects = require('assert-rejects')
+const { startHttpServer } = require('./servers')
+
+describe('Native Fetch', () => {
+  it('input is string', async () => {
+    const scope = nock('http://example.test').get('/').reply()
+
+    const { status } = await fetch('http://example.test/')
+    expect(status).to.equal(200)
+    scope.done()
+  })
+
+  it('input is URL', async () => {
+    const scope = nock('http://example.test').get('/').reply()
+
+    const { status } = await fetch(new URL('http://example.test/'))
+    expect(status).to.equal(200)
+    scope.done()
+  })
+
+  it('input is Request object', async () => {
+    const scope = nock('http://example.test').get('/').reply()
+
+    const { status } = await fetch(new Request('http://example.test/'))
+    expect(status).to.equal(200)
+    scope.done()
+  })
+
+  it('filter by body', async () => {
+    const scope = nock('http://example.test')
+      .post('/', { test: 'fetch' })
+      .reply()
+
+    const { status } = await fetch('http://example.test/', {
+      method: 'POST',
+      body: JSON.stringify({ test: 'fetch' }),
+    })
+    expect(status).to.equal(200)
+    scope.done()
+  })
+
+  it('filter by request body', async () => {
+    const scope = nock('http://example.test')
+      .post('/', { test: 'fetch' })
+      .reply()
+
+    const { status } = await fetch(
+      new Request('http://example.test/', {
+        method: 'POST',
+        body: JSON.stringify({ test: 'fetch' }),
+      }),
+    )
+    expect(status).to.equal(200)
+    scope.done()
+  })
+
+  it('no match', async () => {
+    nock('http://example.test').get('/').reply()
+
+    await assertRejects(
+      fetch('http://example.test/wrong-path'),
+      /Nock: No match for request/,
+    )
+  })
+
+  it('forward request if no mock', async () => {
+    const { origin } = await startHttpServer((request, response) => {
+      response.write('live')
+      response.end()
+    })
+
+    const { status } = await fetch(origin)
+    expect(status).to.equal(200)
+  })
+
+  it('should work with empty response', async () => {
+    nock('http://example.test').get('/').reply(204)
+
+    const { status } = await fetch('http://example.test')
+    expect(status).to.equal(204)
+  })
+
+  it('should work https', async () => {
+    nock('https://example.test').get('/').reply()
+
+    const { status } = await fetch('https://example.test')
+    expect(status).to.equal(200)
+  })
+})