Skip to content

Commit 91a2b3d

Browse files
authored
Fix Undici issues (#4689)
Fixes #4688, #4618 - Bumps the minimum NodeJS version in line with Undici@7 - Fixes `maxRedirections`
1 parent f2a5285 commit 91a2b3d

File tree

4 files changed

+100
-84
lines changed

4 files changed

+100
-84
lines changed

package-lock.json

Lines changed: 17 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@
158158
"vitest": "^3.1.4"
159159
},
160160
"engines": {
161-
"node": ">=18.17"
161+
"node": ">=20.18.1"
162162
},
163163
"tshy": {
164164
"esmDialects": [

src/index.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,24 @@ describe('fromURL', () => {
196196
'Response Error',
197197
);
198198
});
199+
200+
it('should follow redirects', async () => {
201+
let redirected = false;
202+
const port = await createTestServer('text/html', TEST_HTML, (req, res) => {
203+
if (redirected) {
204+
expect(req.url).toBe('/final');
205+
res.writeHead(200, { 'Content-Type': 'text/html' });
206+
res.end(TEST_HTML);
207+
} else {
208+
redirected = true;
209+
res.writeHead(302, { Location: `http://localhost:${port}/final` });
210+
res.end();
211+
}
212+
});
213+
214+
const $ = await cheerio.fromURL(`http://localhost:${port}`);
215+
expect($.html()).toBe(
216+
`<html><head></head><body>${TEST_HTML}</body></html>`,
217+
);
218+
});
199219
});

src/index.ts

Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,10 @@ export function decodeStream(
175175
return decodeStream;
176176
}
177177

178-
type UndiciStreamOptions = Parameters<typeof undici.stream>[1];
178+
type UndiciStreamOptions = Omit<
179+
undici.Dispatcher.RequestOptions<unknown>,
180+
'path'
181+
>;
179182

180183
export interface CheerioRequestOptions extends DecodeStreamOptions {
181184
/** The options passed to `undici`'s `stream` method. */
@@ -184,8 +187,6 @@ export interface CheerioRequestOptions extends DecodeStreamOptions {
184187

185188
const defaultRequestOptions: UndiciStreamOptions = {
186189
method: 'GET',
187-
// Allow redirects by default
188-
maxRedirections: 5,
189190
// Set an Accept header
190191
headers: {
191192
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -222,59 +223,67 @@ export async function fromURL(
222223
let undiciStream: Promise<undici.Dispatcher.StreamData<unknown>> | undefined;
223224

224225
// Add headers if none were supplied.
225-
requestOptions.headers ??= defaultRequestOptions.headers;
226+
const urlObject = typeof url === 'string' ? new URL(url) : url;
227+
const streamOptions = {
228+
headers: defaultRequestOptions.headers,
229+
path: urlObject.pathname + urlObject.search,
230+
...requestOptions,
231+
};
226232

227233
const promise = new Promise<CheerioAPI>((resolve, reject) => {
228-
undiciStream = undici.stream(url, requestOptions, (res) => {
229-
if (res.statusCode < 200 || res.statusCode >= 300) {
230-
throw new undici.errors.ResponseError(
231-
'Response Error',
232-
res.statusCode,
233-
{
234-
headers: res.headers,
235-
},
236-
);
237-
}
238-
239-
const contentTypeHeader = res.headers['content-type'] ?? 'text/html';
240-
const mimeType = new MIMEType(
241-
Array.isArray(contentTypeHeader)
242-
? contentTypeHeader[0]
243-
: contentTypeHeader,
244-
);
245-
246-
if (!mimeType.isHTML() && !mimeType.isXML()) {
247-
throw new RangeError(
248-
`The content-type "${mimeType.essence}" is neither HTML nor XML.`,
234+
undiciStream = new undici.Client(url)
235+
.compose(undici.interceptors.redirect({ maxRedirections: 5 }))
236+
.stream(streamOptions, (res) => {
237+
if (res.statusCode < 200 || res.statusCode >= 300) {
238+
throw new undici.errors.ResponseError(
239+
'Response Error',
240+
res.statusCode,
241+
{
242+
headers: res.headers,
243+
},
244+
);
245+
}
246+
247+
const contentTypeHeader = res.headers['content-type'] ?? 'text/html';
248+
const mimeType = new MIMEType(
249+
Array.isArray(contentTypeHeader)
250+
? contentTypeHeader[0]
251+
: contentTypeHeader,
249252
);
250-
}
251-
252-
// Forward the charset from the header to the decodeStream.
253-
encoding.transportLayerEncodingLabel = mimeType.parameters.get('charset');
254-
255-
/*
256-
* If we allow redirects, we will have entries in the history.
257-
* The last entry will be the final URL.
258-
*/
259-
const history = (
260-
res.context as
261-
| {
262-
history?: URL[];
263-
}
264-
| undefined
265-
)?.history;
266-
267-
const opts = {
268-
encoding,
269-
// Set XML mode based on the MIME type.
270-
xmlMode: mimeType.isXML(),
271-
// Set the `baseURL` to the final URL.
272-
baseURL: history ? history[history.length - 1] : url,
273-
...cheerioOptions,
274-
};
275-
276-
return decodeStream(opts, (err, $) => (err ? reject(err) : resolve($)));
277-
});
253+
254+
if (!mimeType.isHTML() && !mimeType.isXML()) {
255+
throw new RangeError(
256+
`The content-type "${mimeType.essence}" is neither HTML nor XML.`,
257+
);
258+
}
259+
260+
// Forward the charset from the header to the decodeStream.
261+
encoding.transportLayerEncodingLabel =
262+
mimeType.parameters.get('charset');
263+
264+
/*
265+
* If we allow redirects, we will have entries in the history.
266+
* The last entry will be the final URL.
267+
*/
268+
const history = (
269+
res.context as
270+
| {
271+
history?: URL[];
272+
}
273+
| undefined
274+
)?.history;
275+
276+
const opts = {
277+
encoding,
278+
// Set XML mode based on the MIME type.
279+
xmlMode: mimeType.isXML(),
280+
// Set the `baseURL` to the final URL.
281+
baseURL: history ? history[history.length - 1] : url,
282+
...cheerioOptions,
283+
};
284+
285+
return decodeStream(opts, (err, $) => (err ? reject(err) : resolve($)));
286+
});
278287
});
279288

280289
// Let's make sure the request is completed before returning the promise.

0 commit comments

Comments
 (0)