-
-
Notifications
You must be signed in to change notification settings - Fork 657
Description
Description
When using Agent().compose(...) to add custom interceptors, the request’s headers and query data arrive in wildly different shapes depending on how you called request() or fetch(). Users can pass:
- Nothing (opts.headers is undefined)
- Array of strings (['k','v','k2','v2'])
- Plain object ({ k: 'v', k2: 'v2' })
- Headers instance
- Custom iterable (e.g. a user‑defined class with [Symbol.iterator] yielding [name, value] pairs)
Because these values aren’t normalized before your composed interceptors run, every interceptor ends up having to defensively check for multiple shapes:
if (!opts.headers) { … }
else if (Array.isArray(opts.headers)) { … }
else if (opts.headers instanceof Headers) { … }
else if (typeof opts.headers[Symbol.iterator] === 'function') { … }
else if (typeof opts.headers === 'object') { … }
This bloats interceptor code and forces duplication of header‑parsing logic in every piece of middleware (auth, compression, retries, logging, etc.).
Reproduction
import { request, fetch, Agent, Headers, Dispatcher, DecoratorHandler } from 'undici';
function createCustomInterceptor(): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {
console.log(`-> ${opts.method} ${opts.path}, headers`, opts.headers);
class LoggerHandler extends DecoratorHandler {
onResponseStart(controller, status, headers, statusText) {
console.log(`<- ${status} ${statusText}, headers:`, headers);
super.onResponseStart(controller, status, headers, statusText);
}
}
return dispatch(opts, new LoggerHandler(handler));
};
}
const dispatcher = new Agent().compose(createCustomInterceptor());
class CustomHeaders {
raw = [['k','v'], ['k2','v2']];
*entries() { for (const pair of this.raw) yield pair; }
[Symbol.iterator]() { return this.entries(); }
}
// Try all the different ways to pass headers:
await request('https://api.ipify.org/', { dispatcher });
await request('https://api.ipify.org/', { headers: ['k','v','k2','v2'], dispatcher, responseHeaders: 'raw' });
await request('https://api.ipify.org/', { headers: { k: 'v', k2: 'v2' }, dispatcher });
await request('https://api.ipify.org/', { headers: new Headers([['k','v'],['k2','v2']]), dispatcher });
await request('https://api.ipify.org/', { headers: new CustomHeaders(), dispatcher });
await fetch ('https://api.ipify.org/', { headers: new Headers([['k','v'],['k2','v2']]), dispatcher });
await fetch ('https://api.ipify.org/', { headers: new CustomHeaders(), dispatcher });
Observed output:
-> GET /, headers undefined
-> GET /, headers [ 'k', 'v', 'k2', 'v2' ]
-> GET /, headers { k: 'v', k2: 'v2' }
-> GET /, headers Headers { k: 'v', k2: 'v2' }
-> GET /, headers CustomHeaders {}
…
Expected Behavior
All interceptors should receive a normalized version of:
- opts.headers as a consistent map of lower-cased header names to string values
- opts.query/opts.searchParams similarly normalized into a single, predictable shape
Regardless of how the caller passed them in—arrays, objects, Headers, or any iterable—the dispatcher should convert into one uniform form so that interceptors only ever have to deal with, for example, a plain object { [header: string]: string } or a Headers instance or other HeaderMap structures without validated logical.
Impact
Without dispatcher‑level normalization:
- Boilerplate appears in every interceptor to detect and normalize header shapes.
- Code duplication and potential for subtle bugs increase.
- Reusable, composable middleware becomes harder to write and maintain.
Proposed Solution
- Create a HeadersMap structure that will transmit headers in a single format inside the core, We need a structure with methods like Headers.
- Pre-process opts.headers (and opts.query / opts.searchParams) in the core dispatcher before invoking any composed interceptors.