diff --git a/.changeset/xhr-json-response-normalization.md b/.changeset/xhr-json-response-normalization.md new file mode 100644 index 0000000..24ef8b5 --- /dev/null +++ b/.changeset/xhr-json-response-normalization.md @@ -0,0 +1,5 @@ +--- +"@hsblabs/fetch-interceptor": patch +--- + +Normalize JSON-style XMLHttpRequest responses into a readable standard `Response` body. diff --git a/src/index.test.ts b/src/index.test.ts index e1618c7..a33145d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -20,6 +20,7 @@ type MockXhrResponse = { headers?: Record; response?: XMLHttpRequest["response"]; responseText?: string; + responseType?: XMLHttpRequestResponseType; status?: number; statusText?: string; }; @@ -50,6 +51,7 @@ class MockXMLHttpRequest { status = 0; statusText = ""; response: XMLHttpRequest["response"] = null; + responseType: XMLHttpRequestResponseType = ""; responseText = ""; open(_method: string, _url: string | URL, ..._args: unknown[]) {} @@ -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 : ""); @@ -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")); diff --git a/src/xhr.test.ts b/src/xhr.test.ts index 3c02182..43b844c 100644 --- a/src/xhr.test.ts +++ b/src/xhr.test.ts @@ -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", @@ -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", () => { @@ -87,6 +116,7 @@ describe("createXhrLoadHandler", () => { { getAllResponseHeaders: () => "content-type: text/plain", response: "ok", + responseType: "", responseText: "fallback", status: 200, statusText: "OK", @@ -114,6 +144,7 @@ describe("createXhrLoadHandler", () => { { getAllResponseHeaders: () => "", response: null, + responseType: "", responseText: "", status: 204, statusText: "No Content", @@ -139,6 +170,7 @@ describe("createXhrLoadHandler", () => { { getAllResponseHeaders: () => "", response: "ok", + responseType: "", responseText: "ok", status: 200, statusText: "OK", @@ -165,6 +197,7 @@ describe("createXhrLoadHandler", () => { { getAllResponseHeaders: () => "", response: "ok", + responseType: "", responseText: "ok", status: 200, statusText: "OK", diff --git a/src/xhr.ts b/src/xhr.ts index bb18f7b..19c8667 100644 --- a/src/xhr.ts +++ b/src/xhr.ts @@ -21,6 +21,7 @@ type XhrResponseSource = Pick< XMLHttpRequest, | "getAllResponseHeaders" | "response" + | "responseType" | "responseText" | "status" | "statusText" @@ -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,