Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/net/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cacheable/net",
"version": "2.0.7",
"version": "2.0.8",
"description": "High Performance Network Caching for Node.js with fetch, request, http 1.1, and http 2 support",
"type": "module",
"main": "./dist/index.js",
Expand Down
24 changes: 15 additions & 9 deletions packages/net/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { Cacheable } from "cacheable";
import CachePolicy from "http-cache-semantics";
import {
type RequestInit,
type Response as UndiciResponse,
fetch as undiciFetch,
} from "undici";
import type { RequestInit, Response as UndiciResponse } from "undici";

// Use the runtime's own fetch so body classes (FormData, Blob, File,
// URLSearchParams, ReadableStream) come from the same realm. Importing
// fetch from a standalone undici version causes its instanceof checks to
// reject globals from Node's bundled undici, leading to FormData being
// silently coerced to "[object FormData]" with Content-Type: text/plain.
const runtimeFetch = globalThis.fetch.bind(globalThis) as unknown as (
input: string,
init?: RequestInit,
) => Promise<UndiciResponse>;

export type FetchOptions = Omit<RequestInit, "cache"> & {
cache?: Cacheable;
Expand Down Expand Up @@ -57,7 +63,7 @@ export async function fetch(

// If no cache provided, skip all caching logic
if (!options.cache) {
const response = await undiciFetch(url, fetchOptions);
const response = await runtimeFetch(url, fetchOptions);
/* c8 ignore next 3 */
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
Expand All @@ -72,7 +78,7 @@ export async function fetch(
options.method === "DELETE" ||
options.method === "HEAD"
) {
const response = await undiciFetch(url, fetchOptions);
const response = await runtimeFetch(url, fetchOptions);
/* c8 ignore next 3 */
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
Expand All @@ -90,7 +96,7 @@ export async function fetch(
// Simple caching without HTTP cache semantics
const cachedData = await options.cache.getOrSet(cacheKey, async () => {
// Perform the fetch operation
const response = await undiciFetch(url, fetchOptions);
const response = await runtimeFetch(url, fetchOptions);
/* v8 ignore next -- @preserve */
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
Expand Down Expand Up @@ -178,7 +184,7 @@ export async function fetch(
}

// Make the fetch request
const response = await undiciFetch(url, {
const response = await runtimeFetch(url, {
...fetchOptions,
headers: {
...fetchOptions.headers,
Expand Down
144 changes: 143 additions & 1 deletion packages/net/test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import process from "node:process";
import { Cacheable } from "cacheable";
import { describe, expect, test } from "vitest";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
del,
type FetchOptions,
Expand Down Expand Up @@ -1008,4 +1010,144 @@ describe("Fetch", () => {
testTimeout,
);
});

// Regression: @cacheable/net@2.0.7 imported fetch from a standalone
// undici version whose FormData class did not match the global
// FormData created by the user. Its instanceof check failed, the body
// fell through to a string coercion, and the request went out as
// "[object FormData]" with Content-Type: text/plain. These tests use a
// local HTTP server to assert that the wire-level body and content-type
// are correct for every BodyInit shape.
describe("Body coercion (local server)", () => {
type CapturedRequest = {
contentType: string | undefined;
body: Buffer;
};
const captured: CapturedRequest[] = [];
let baseUrl = "";
let server: http.Server;

beforeAll(async () => {
server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
captured.push({
contentType: req.headers["content-type"],
body: Buffer.concat(chunks),
});
res.writeHead(200, { "content-type": "application/json" });
res.end('{"ok":true}');
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});

test("post sends FormData as multipart, not [object FormData]", async () => {
captured.length = 0;
const formData = new FormData();
formData.append("foo", "bar");
formData.append("baz", "qux");

await post(baseUrl, formData, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toMatch(/^multipart\/form-data; boundary=/);
const bodyText = body.toString("utf8");
expect(bodyText).not.toContain("[object FormData]");
expect(bodyText).toContain('name="foo"');
expect(bodyText).toContain("bar");
expect(bodyText).toContain('name="baz"');
expect(bodyText).toContain("qux");
});

test("post sends FormData with File as multipart with filename", async () => {
captured.length = 0;
const formData = new FormData();
formData.append(
"upload",
new File(["hello world"], "greet.txt", { type: "text/plain" }),
);

await post(baseUrl, formData, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toMatch(/^multipart\/form-data; boundary=/);
const bodyText = body.toString("utf8");
expect(bodyText).toContain('filename="greet.txt"');
expect(bodyText).toContain("hello world");
});

test("post sends URLSearchParams as form-urlencoded", async () => {
captured.length = 0;
const params = new URLSearchParams();
params.append("foo", "bar");
params.append("baz", "qux");

await post(baseUrl, params, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toMatch(/^application\/x-www-form-urlencoded/);
expect(body.toString("utf8")).toBe("foo=bar&baz=qux");
});

test("post sends Blob with its declared type", async () => {
captured.length = 0;
const blob = new Blob(["hello"], { type: "text/plain" });

await post(baseUrl, blob, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toBe("text/plain");
expect(body.toString("utf8")).toBe("hello");
});

test("post sends a JSON object as application/json", async () => {
captured.length = 0;
await post(baseUrl, { hello: "world" }, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toBe("application/json");
expect(JSON.parse(body.toString("utf8"))).toEqual({ hello: "world" });
});

test("patch sends FormData as multipart", async () => {
captured.length = 0;
const formData = new FormData();
formData.append("k", "v");

await patch(baseUrl, formData, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toMatch(/^multipart\/form-data; boundary=/);
expect(body.toString("utf8")).toContain('name="k"');
});

test("del sends FormData as multipart", async () => {
captured.length = 0;
const formData = new FormData();
formData.append("id", "123");

await del(baseUrl, formData, { cache: new Cacheable() });

expect(captured).toHaveLength(1);
const [{ contentType, body }] = captured;
expect(contentType).toMatch(/^multipart\/form-data; boundary=/);
expect(body.toString("utf8")).toContain('name="id"');
});
});
});
Loading