Repository: nodejs/undici (JavaScript)

Issue:
Bind dispatcher to Request Object
## This would solve...

From the whatwg spec we can [read](https://github.com/nodejs/undici/blob/7623f106e2ff9eb2cd9c38a80e0e7be0a78f0f61/lib/web/fetch/index.js#L130-L133) `Let requestObject be the result of invoking the initial value of Request as constructor with input and init as arguments.`

This makes it seem like the two following are exactly equivalent, no matter what `input` and `init` are:

```ts
fetch(input, init)
fetch(new Request(input, init))
```

This is mostly true, with the exception of `init.dispatcher` (I know, not defined by the spec):

https://github.com/nodejs/undici/blob/7623f106e2ff9eb2cd9c38a80e0e7be0a78f0f61/lib/web/fetch/index.js#L242

I believe it would make more sense to be able to attach a dispatcher directly to the request object so that the behavior defined by the spec also applies to the custom(s) options added by `undici`.

## The implementation should look like...

Similar to the `body` Request attribute, `dispatcher` would be set as an attribute of the request that can be overridden using `init`.

## I have also considered...

Status quo implies that if we are only working with Request object as `input`, we still need to carry over the `init` for the sole purpose of being able to pass a custom `dispatcher` to undici.

## Additional context

I created a bunch of utilities to work with `fetch()`. Being able to work with a single value makes a lot of things simpler. Consider for example the following wrapper:

```ts
const logWrap = fetch => async (input, init) => {
  const request = new Request(input, init) // Should be fine per Whatwg spec
  console.log(request.method, request.uri)
  const response = await fetch(request)
  console.log(response.status)
  return response
}
```

Being able to use `new Request` to be able to compute the merge of `input` and `init` is super useful. This causes however an issue when working with `undici` because of the additionnal `dispatcher` option.

Fixing this requires to do the following:

```ts
const logWrap = fetch => async (input, init) => {
  const request = new Request(input, init)
  console.log(request.method, request.uri)
  const response = await fetch(request, { dispatcher: init?.dispatcher }) // undici specific
  console.log(response.status)
  return response
}
```

This implementation is not agnostic of the actual `fetch` implementation used under the hood, which breaks separation of concerns.


Discussion:
cc @KhafraDev 
Thanks guys ❤️



Patch:
diff --git a/lib/web/cache/cache.js b/lib/web/cache/cache.js
index acbd6c7d0f7..80d9e2f15f3 100644
--- a/lib/web/cache/cache.js
+++ b/lib/web/cache/cache.js
@@ -10,7 +10,6 @@ const { kState } = require('../fetch/symbols')
 const { fetching } = require('../fetch/index')
 const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util')
 const assert = require('node:assert')
-const { getGlobalDispatcher } = require('../../global')
 
 /**
  * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation
@@ -150,7 +149,6 @@ class Cache {
       // 5.7
       fetchControllers.push(fetching({
         request: r,
-        dispatcher: getGlobalDispatcher(),
         processResponse (response) {
           // 1.
           if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) {
diff --git a/lib/web/eventsource/eventsource.js b/lib/web/eventsource/eventsource.js
index ad6ea26dcd1..6b34976e31b 100644
--- a/lib/web/eventsource/eventsource.js
+++ b/lib/web/eventsource/eventsource.js
@@ -9,7 +9,6 @@ const { EventSourceStream } = require('./eventsource-stream')
 const { parseMIMEType } = require('../fetch/dataURL')
 const { MessageEvent } = require('../websocket/events')
 const { isNetworkError } = require('../fetch/response')
-const { getGlobalDispatcher } = require('../../global')
 const { delay } = require('./util')
 
 let experimentalWarned = false
@@ -316,10 +315,7 @@ class EventSource extends EventTarget {
         })
     }
 
-    this.#controller = fetching({
-      ...fetchParam,
-      dispatcher: getGlobalDispatcher()
-    })
+    this.#controller = fetching(fetchParam)
   }
 
   /**
diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js
index df2f442c647..e9733362e95 100644
--- a/lib/web/fetch/index.js
+++ b/lib/web/fetch/index.js
@@ -47,7 +47,7 @@ const {
   createInflate,
   extractMimeType
 } = require('./util')
-const { kState } = require('./symbols')
+const { kState, kDispatcher } = require('./symbols')
 const assert = require('node:assert')
 const { safelyExtractBody, extractBody } = require('./body')
 const {
@@ -239,7 +239,7 @@ function fetch (input, init = undefined) {
     request,
     processResponseEndOfBody: handleFetchDone,
     processResponse,
-    dispatcher: init?.dispatcher ?? getGlobalDispatcher() // undici
+    dispatcher: requestObject[kDispatcher] // undici
   })
 
   // 14. Return p.
@@ -361,7 +361,7 @@ function fetching ({
   processResponseEndOfBody,
   processResponseConsumeBody,
   useParallelQueue = false,
-  dispatcher // undici
+  dispatcher = getGlobalDispatcher() // undici
 }) {
   // Ensure that the dispatcher is set accordingly
   assert(dispatcher)
diff --git a/lib/web/fetch/request.js b/lib/web/fetch/request.js
index ef2f3ff0b14..f8759626a1e 100644
--- a/lib/web/fetch/request.js
+++ b/lib/web/fetch/request.js
@@ -24,7 +24,7 @@ const {
   requestDuplex
 } = require('./constants')
 const { kEnumerableProperty } = util
-const { kHeaders, kSignal, kState, kGuard, kRealm } = require('./symbols')
+const { kHeaders, kSignal, kState, kGuard, kRealm, kDispatcher } = require('./symbols')
 const { webidl } = require('./webidl')
 const { getGlobalOrigin } = require('./global')
 const { URLSerializer } = require('./dataURL')
@@ -78,6 +78,8 @@ class Request {
 
     // 5. If input is a string, then:
     if (typeof input === 'string') {
+      this[kDispatcher] = init.dispatcher
+
       // 1. Let parsedURL be the result of parsing input with baseURL.
       // 2. If parsedURL is failure, then throw a TypeError.
       let parsedURL
@@ -101,6 +103,8 @@ class Request {
       // 5. Set fallbackMode to "cors".
       fallbackMode = 'cors'
     } else {
+      this[kDispatcher] = input[kDispatcher]
+
       // 6. Otherwise:
 
       // 7. Assert: input is a Request object.
@@ -979,6 +983,10 @@ webidl.converters.RequestInit = webidl.dictionaryConverter([
     key: 'duplex',
     converter: webidl.converters.DOMString,
     allowedValues: requestDuplex
+  },
+  {
+    key: 'dispatcher', // undici specific option
+    converter: webidl.converters.any
   }
 ])
 
diff --git a/lib/web/fetch/symbols.js b/lib/web/fetch/symbols.js
index 0b947d55bad..e98e78080b6 100644
--- a/lib/web/fetch/symbols.js
+++ b/lib/web/fetch/symbols.js
@@ -6,5 +6,6 @@ module.exports = {
   kSignal: Symbol('signal'),
   kState: Symbol('state'),
   kGuard: Symbol('guard'),
-  kRealm: Symbol('realm')
+  kRealm: Symbol('realm'),
+  kDispatcher: Symbol('dispatcher')
 }
diff --git a/lib/web/fetch/webidl.js b/lib/web/fetch/webidl.js
index da5df4a362f..41f5813db69 100644
--- a/lib/web/fetch/webidl.js
+++ b/lib/web/fetch/webidl.js
@@ -3,7 +3,7 @@
 const { types } = require('node:util')
 const { toUSVString } = require('../../core/util')
 
-/** @type {import('../../types/webidl').Webidl} */
+/** @type {import('../../../types/webidl').Webidl} */
 const webidl = {}
 webidl.converters = {}
 webidl.util = {}
diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js
index 33905404833..8f12933c959 100644
--- a/lib/web/websocket/connection.js
+++ b/lib/web/websocket/connection.js
@@ -13,7 +13,6 @@ const { CloseEvent } = require('./events')
 const { makeRequest } = require('../fetch/request')
 const { fetching } = require('../fetch/index')
 const { Headers } = require('../fetch/headers')
-const { getGlobalDispatcher } = require('../../global')
 const { kHeadersList } = require('../../core/symbols')
 
 /** @type {import('crypto')} */
@@ -100,7 +99,7 @@ function establishWebSocketConnection (url, protocols, ws, onEstablish, options)
   const controller = fetching({
     request,
     useParallelQueue: true,
-    dispatcher: options.dispatcher ?? getGlobalDispatcher(),
+    dispatcher: options.dispatcher,
     processResponse (response) {
       // 1. If response is a network error or its status is not 101,
       //    fail the WebSocket connection.


Test patch:
diff --git a/test/fetch/issue-2828.js b/test/fetch/issue-2828.js
new file mode 100644
index 00000000000..08e8ea714ca
--- /dev/null
+++ b/test/fetch/issue-2828.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const { once } = require('node:events')
+const { createServer } = require('node:http')
+const { test } = require('node:test')
+const { Agent, Request, fetch } = require('../..')
+const { tspl } = require('@matteo.collina/tspl')
+
+test('issue #2828, dispatcher is allowed in RequestInit options', async (t) => {
+  const { deepStrictEqual } = tspl(t, { plan: 1 })
+
+  class CustomAgent extends Agent {
+    dispatch (options, handler) {
+      options.headers['x-my-header'] = 'hello'
+      return super.dispatch(...arguments)
+    }
+  }
+
+  const server = createServer((req, res) => {
+    deepStrictEqual(req.headers['x-my-header'], 'hello')
+    res.end()
+  }).listen(0)
+
+  t.after(server.close.bind(server))
+  await once(server, 'listening')
+
+  const request = new Request(`http://localhost:${server.address().port}`, {
+    dispatcher: new CustomAgent()
+  })
+
+  await fetch(request)
+})