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
5 changes: 5 additions & 0 deletions .changeset/xhr-json-response-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hsblabs/fetch-interceptor": patch
---

Normalize JSON-style XMLHttpRequest responses into a readable standard `Response` body.
37 changes: 37 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type MockXhrResponse = {
headers?: Record<string, string>;
response?: XMLHttpRequest["response"];
responseText?: string;
responseType?: XMLHttpRequestResponseType;
status?: number;
statusText?: string;
};
Expand Down Expand Up @@ -50,6 +51,7 @@ class MockXMLHttpRequest {
status = 0;
statusText = "";
response: XMLHttpRequest["response"] = null;
responseType: XMLHttpRequestResponseType = "";
responseText = "";

open(_method: string, _url: string | URL, ..._args: unknown[]) {}
Expand All @@ -72,6 +74,7 @@ class MockXMLHttpRequest {
this.status = nextResponse.status ?? 200;
this.statusText = nextResponse.statusText ?? "OK";
this.response = nextResponse.response ?? nextResponse.body ?? null;
this.responseType = nextResponse.responseType ?? this.responseType;
this.responseText =
nextResponse.responseText ??
(typeof nextResponse.body === "string" ? nextResponse.body : "");
Expand Down Expand Up @@ -220,6 +223,40 @@ describe("createFetchInterceptor", () => {
interceptor.stop();
});

it("normalizes JSON XMLHttpRequest responses into a readable Response body", async () => {
const intercepted = createDeferred<{
request: Request;
response: Response;
}>();

useMockXmlHttpRequest();
MockXMLHttpRequest.enqueueResponse({
headers: {
"content-type": "application/json",
},
response: { ok: true, version: 1 },
});

const interceptor = createFetchInterceptor({
onIntercept: (request, response) =>
intercepted.resolve({ request, response }),
});

interceptor.start();

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://example.com/xhr-json");
xhr.responseType = "json";
xhr.send();

const { request, response } = await intercepted.promise;

expect(request.url).toBe("https://example.com/xhr-json");
expect(await response.json()).toEqual({ ok: true, version: 1 });

interceptor.stop();
});

it("starts only once and restores the original globals on stop", async () => {
const onIntercept = vi.fn();
const originalFetch = vi.fn(async () => new Response("ok"));
Expand Down
33 changes: 33 additions & 0 deletions src/xhr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("createXhrResponse", () => {
getAllResponseHeaders: () =>
"content-type: application/json\r\nx-request-id: req-1",
response: JSON.stringify({ ok: true }),
responseType: "",
responseText: "fallback",
status: 202,
statusText: "Accepted",
Expand All @@ -75,6 +76,34 @@ describe("createXhrResponse", () => {
expect(response.headers.get("x-request-id")).toBe("req-1");
expect(await response.json()).toEqual({ ok: true });
});

it("serializes JSON response objects before constructing the Response", async () => {
const response = createXhrResponse({
getAllResponseHeaders: () => "content-type: application/json",
response: { nested: { ok: true } },
responseType: "json",
responseText: "",
status: 200,
statusText: "OK",
});

expect(await response.json()).toEqual({ nested: { ok: true } });
});

it("treats unreadable responseText as an empty body", async () => {
const response = createXhrResponse({
getAllResponseHeaders: () => "",
response: null,
responseType: "json",
get responseText() {
throw new Error("InvalidStateError");
},
status: 200,
statusText: "OK",
});

expect(await response.text()).toBe("");
});
});

describe("createXhrLoadHandler", () => {
Expand All @@ -87,6 +116,7 @@ describe("createXhrLoadHandler", () => {
{
getAllResponseHeaders: () => "content-type: text/plain",
response: "ok",
responseType: "",
responseText: "fallback",
status: 200,
statusText: "OK",
Expand Down Expand Up @@ -114,6 +144,7 @@ describe("createXhrLoadHandler", () => {
{
getAllResponseHeaders: () => "",
response: null,
responseType: "",
responseText: "",
status: 204,
statusText: "No Content",
Expand All @@ -139,6 +170,7 @@ describe("createXhrLoadHandler", () => {
{
getAllResponseHeaders: () => "",
response: "ok",
responseType: "",
responseText: "ok",
status: 200,
statusText: "OK",
Expand All @@ -165,6 +197,7 @@ describe("createXhrLoadHandler", () => {
{
getAllResponseHeaders: () => "",
response: "ok",
responseType: "",
responseText: "ok",
status: 200,
statusText: "OK",
Expand Down
41 changes: 40 additions & 1 deletion src/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type XhrResponseSource = Pick<
XMLHttpRequest,
| "getAllResponseHeaders"
| "response"
| "responseType"
| "responseText"
| "status"
| "statusText"
Expand Down Expand Up @@ -81,8 +82,46 @@ export function parseXhrResponseHeaders(rawHeaders: string): Headers {
return headers;
}

function readXhrResponseText(xhr: XhrResponseSource): string | null {
try {
return xhr.responseText;
} catch {
return null;
}
}

function toResponseBody(xhr: XhrResponseSource): BodyInit | null {
const { response, responseType } = xhr;

if (response == null) {
return readXhrResponseText(xhr);
}

if (typeof response === "string") {
return response;
}

if (typeof Blob !== "undefined" && response instanceof Blob) {
return response;
}

if (response instanceof ArrayBuffer || ArrayBuffer.isView(response)) {
return response;
}

if (typeof Document !== "undefined" && response instanceof Document) {
return new XMLSerializer().serializeToString(response);
}

if (responseType === "json") {
return JSON.stringify(response);
}

return readXhrResponseText(xhr);
}

export function createXhrResponse(xhr: XhrResponseSource): Response {
const responseBody = xhr.response ?? xhr.responseText;
const responseBody = toResponseBody(xhr);

return new Response(responseBody, {
status: xhr.status,
Expand Down
Loading