Skip to content

Commit ee04514

Browse files
authored
Merge pull request #8 from minpeter/fix/webfetch-body-disposal
fix(webfetch): handle bodies without dump
2 parents 268b49c + 649d190 commit ee04514

4 files changed

Lines changed: 104 additions & 14 deletions

File tree

src/webfetch/fetcher.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createServer, type Server } from "node:http";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
4+
import { fetchUrl } from "./fetcher.js";
5+
6+
const servers: Server[] = [];
7+
8+
async function createFixtureServer(): Promise<string> {
9+
const server = createServer((request, response) => {
10+
if (request.url === "/start") {
11+
response.writeHead(302, {
12+
location: "/final",
13+
"content-type": "text/plain; charset=utf-8",
14+
});
15+
response.end("redirecting");
16+
return;
17+
}
18+
19+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
20+
response.end("ready");
21+
});
22+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
23+
const address = server.address();
24+
if (typeof address !== "object" || address === null) {
25+
throw new Error("Expected TCP server address");
26+
}
27+
servers.push(server);
28+
return `http://127.0.0.1:${address.port}`;
29+
}
30+
31+
afterEach(async () => {
32+
await Promise.all(
33+
servers.splice(0).map(
34+
(server) =>
35+
new Promise<void>((resolve, reject) => {
36+
server.close((error) => (error ? reject(error) : resolve()));
37+
}),
38+
),
39+
);
40+
});
41+
42+
describe("fetchUrl", () => {
43+
it("discards a response body without dump", async () => {
44+
// given
45+
const baseUrl = await createFixtureServer();
46+
47+
// when
48+
const result = await fetchUrl({
49+
url: `${baseUrl}/start`,
50+
format: "text",
51+
timeoutSeconds: 1,
52+
});
53+
54+
// then
55+
expect(new TextDecoder().decode(result.body)).toBe("ready");
56+
expect(result.url).toBe(`${baseUrl}/final`);
57+
});
58+
});

src/webfetch/fetcher.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ interface HttpResponse {
4747

4848
interface ResponseBodyStream extends AsyncIterable<unknown> {
4949
destroy(error?: Error): void;
50-
dump(options?: { limit: number; signal?: AbortSignal }): Promise<void>;
50+
dump?(options?: { limit: number; signal?: AbortSignal }): Promise<void>;
51+
once(event: "error", listener: (error: Error) => void): unknown;
5152
}
5253

5354
export async function fetchUrl(options: FetchOptions): Promise<FetchResult> {
@@ -209,17 +210,28 @@ function getHeader(headers: IncomingHttpHeaders, name: string): string {
209210
}
210211

211212
async function discardBody(body: ResponseBodyStream): Promise<void> {
213+
if (typeof body.dump !== "function") {
214+
destroyDiscardedBody(body);
215+
return;
216+
}
217+
212218
try {
213219
await body.dump({ limit: 1024 });
214220
} catch (error) {
215221
if (error instanceof Error) {
216-
body.destroy(error);
222+
destroyDiscardedBody(body);
217223
return;
218224
}
219225
throw error;
220226
}
221227
}
222228

229+
function destroyDiscardedBody(body: ResponseBodyStream): void {
230+
// Deliberate teardown can emit an error after the body no longer has a consumer.
231+
body.once("error", () => undefined);
232+
body.destroy();
233+
}
234+
223235
async function readResponseBody(response: HttpResponse, signal: AbortSignal): Promise<Uint8Array> {
224236
const chunks: Uint8Array[] = [];
225237
let total = 0;
Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22

3-
const readability = vi.hoisted(() => ({
4-
parse: vi.fn(() => {
5-
throw new Error("Readability should not run for explicit article matches");
6-
}),
7-
}));
3+
let readabilityParseCalls = 0;
84

95
vi.mock("@mozilla/readability", () => ({
106
Readability: class {
117
parse(): unknown {
12-
return readability.parse();
8+
readabilityParseCalls += 1;
9+
return null;
1310
}
1411
},
1512
}));
@@ -29,17 +26,13 @@ function explicitArticleHtml(): string {
2926
}
3027

3128
describe("webfetch explicit article extraction", () => {
32-
beforeEach(() => {
33-
readability.parse.mockClear();
34-
});
35-
3629
it("#given an explicit article container #when converting markdown #then skips Readability fallback parsing", () => {
3730
// given / when
3831
const markdown = htmlToMarkdown(explicitArticleHtml(), "https://example.test/post");
3932

4033
// then
4134
expect(markdown).toContain("# Explicit Article");
4235
expect(markdown).toContain("Explicit article body");
43-
expect(readability.parse).not.toHaveBeenCalled();
36+
expect(readabilityParseCalls).toBe(0);
4437
});
4538
});

test/webfetch.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,33 @@ describe("webfetch", () => {
563563
expect(headerValue(challengeHeaders, "sec-ch-ua-platform")).toBe('"Windows"');
564564
});
565565

566+
it("#given one redirect #when fetching #then returns the final response body", async () => {
567+
// given
568+
const visitedPaths: string[] = [];
569+
const server = await createFixtureServer((request, response) => {
570+
visitedPaths.push(request.url ?? "");
571+
if (request.url === "/start") {
572+
response.writeHead(302, {
573+
location: "/final",
574+
"content-type": "text/plain; charset=utf-8",
575+
});
576+
response.end("redirecting");
577+
return;
578+
}
579+
580+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
581+
response.end("<html><body><h1>Redirect Complete</h1><p>Final page.</p></body></html>");
582+
});
583+
584+
// when
585+
const result = await executeWebfetch({ url: `${server.baseUrl}/start`, format: "markdown" });
586+
587+
// then
588+
expect(textContent(result)).toContain("# Redirect Complete");
589+
expect(textContent(result)).toContain("Final page.");
590+
expect(visitedPaths).toEqual(["/start", "/final"]);
591+
});
592+
566593
it("#given too many redirects #when fetching #then returns the final redirect response body", async () => {
567594
// given
568595
const server = await createFixtureServer((_request, response) => {

0 commit comments

Comments
 (0)