Skip to content

Instantly share code, notes, and snippets.

@wch
Last active March 7, 2025 15:22
Show Gist options
  • Save wch/5485051a0d79ffc024d2e7b1c92e7fe8 to your computer and use it in GitHub Desktop.
Save wch/5485051a0d79ffc024d2e7b1c92e7fe8 to your computer and use it in GitHub Desktop.
XMLHttpRequest shim
// ===================================================================
// XMLHttpRequestShim - shim for XMLHttpRequest
// This assumes the XMLHttpRequest types from lib.dom.d.ts are available
//
// Based on https://github.com/apple502j/xhr-shim
// ===================================================================
const sHeaders = Symbol("headers");
const sRespHeaders = Symbol("response headers");
const sAbortController = Symbol("AbortController");
const sMethod = Symbol("method");
const sURL = Symbol("URL");
const sMIME = Symbol("MIME");
const sDispatch = Symbol("dispatch");
const sErrored = Symbol("errored");
const sTimeout = Symbol("timeout");
const sTimedOut = Symbol("timedOut");
const sIsResponseText = Symbol("isResponseText");
// type XMLHttpRequestResponseType = "" | "text" | "arraybuffer" | "blob" | "json";
// type XMLHttpRequestEventMap = {
// abort: Event;
// error: Event;
// load: Event;
// loadend: Event;
// loadstart: Event;
// progress: Event;
// timeout: Event;
// };
class XMLHttpRequestShim extends EventTarget implements XMLHttpRequest {
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/naming-convention */
// Public properties
public readyState: number;
public response: any;
public responseType: XMLHttpRequestResponseType;
public responseURL: string;
public status: number;
public statusText: string;
public timeout: number;
public withCredentials: boolean;
// Symbol-based private properties
private [sHeaders]: Record<string, string>;
private [sRespHeaders]: Record<string, string>;
private [sAbortController]: AbortController;
private [sMethod]: string;
private [sURL]: string;
private [sMIME]: string;
private [sErrored]: boolean;
private [sTimeout]: number | NodeJS.Timeout;
private [sTimedOut]: boolean;
private [sIsResponseText]: boolean;
// Event handlers
public onabort: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onerror: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onload: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onloadend: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onloadstart: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onprogress: ((this: XMLHttpRequest, ev: Event) => any) | null;
public ontimeout: ((this: XMLHttpRequest, ev: Event) => any) | null;
public onreadystatechange: ((this: XMLHttpRequest, ev: Event) => any) | null;
readonly UNSENT = 0;
readonly OPENED = 1;
readonly HEADERS_RECEIVED = 2;
readonly LOADING = 3;
readonly DONE = 4;
static readonly UNSENT = 0;
static readonly OPENED = 1;
static readonly HEADERS_RECEIVED = 2;
static readonly LOADING = 3;
static readonly DONE = 4;
constructor() {
super();
this.readyState = XMLHttpRequestShim.UNSENT;
this.response = null;
this.responseType = "";
this.responseURL = "";
this.status = 0;
this.statusText = "";
this.timeout = 0;
this.withCredentials = false;
this[sHeaders] = Object.create(null);
this[sHeaders].accept = "*/*";
this[sRespHeaders] = Object.create(null);
this[sAbortController] = new AbortController();
this[sMethod] = "";
this[sURL] = "";
this[sMIME] = "";
this[sErrored] = false;
this[sTimeout] = 0;
this[sTimedOut] = false;
this[sIsResponseText] = true;
// Initialize event handlers
this.onabort = null;
this.onerror = null;
this.onload = null;
this.onloadend = null;
this.onloadstart = null;
this.onprogress = null;
this.ontimeout = null;
this.onreadystatechange = null;
}
get responseText(): string {
if (this[sErrored]) {
throw new DOMException(
"An error occurred while loading the resource",
"InvalidStateError"
);
}
if (this.readyState < this.HEADERS_RECEIVED) return "";
if (this[sIsResponseText]) return this.response as string;
throw new DOMException(
"Response type not set to text",
"InvalidStateError"
);
}
get responseXML(): never {
throw new Error("XML not supported");
}
private [sDispatch](evt: Event): void {
const attr = `on${evt.type}` as keyof XMLHttpRequestShim;
const handler = this[attr] as
| ((this: XMLHttpRequestShim, ev: Event) => any)
| null;
if (typeof handler === "function") {
this.addEventListener(evt.type, handler.bind(this), {
once: true,
});
}
this.dispatchEvent(evt);
}
abort(): void {
this[sAbortController].abort();
this.status = 0;
this.readyState = XMLHttpRequestShim.UNSENT;
}
open(method: string, url: string): void {
this.status = 0;
this[sMethod] = method;
this[sURL] = url;
this.readyState = XMLHttpRequestShim.OPENED;
}
setRequestHeader(header: string, value: string): void {
header = String(header).toLowerCase();
if (typeof this[sHeaders][header] === "undefined") {
this[sHeaders][header] = String(value);
} else {
this[sHeaders][header] += `, ${value}`;
}
}
overrideMimeType(mimeType: string): void {
this[sMIME] = String(mimeType);
}
getAllResponseHeaders(): string {
if (
this[sErrored] ||
this.readyState < XMLHttpRequestShim.HEADERS_RECEIVED
) {
return "";
}
return Object.entries(this[sRespHeaders])
.map(([header, value]) => `${header}: ${value}`)
.join("\r\n");
}
getResponseHeader(headerName: string): string | null {
const value = this[sRespHeaders][String(headerName).toLowerCase()];
return typeof value === "string" ? value : null;
}
send(body?: Document | XMLHttpRequestBodyInit | null): void {
if (this.timeout > 0) {
this[sTimeout] = setTimeout(() => {
this[sTimedOut] = true;
this[sAbortController].abort();
}, this.timeout);
}
const responseType = this.responseType || "text";
this[sIsResponseText] = responseType === "text";
fetch(this[sURL], {
method: this[sMethod] || "GET",
signal: this[sAbortController].signal,
headers: this[sHeaders],
credentials: this.withCredentials ? "include" : "same-origin",
body: body as BodyInit,
})
.finally(() => {
this.readyState = XMLHttpRequestShim.DONE;
clearTimeout(this[sTimeout] as NodeJS.Timeout);
this[sDispatch](new CustomEvent("loadstart"));
})
.then(
async (resp) => {
this.responseURL = resp.url;
this.status = resp.status;
this.statusText = resp.statusText;
const finalMIME =
this[sMIME] || this[sRespHeaders]["content-type"] || "text/plain";
Object.assign(this[sRespHeaders], resp.headers);
switch (responseType) {
case "text":
this.response = await resp.text();
break;
case "blob":
this.response = new Blob([await resp.arrayBuffer()], {
type: finalMIME,
});
break;
case "arraybuffer":
this.response = await resp.arrayBuffer();
break;
case "json":
this.response = await resp.json();
break;
}
this[sDispatch](new CustomEvent("load"));
},
(err) => {
let eventName = "abort";
if (err.name !== "AbortError") {
this[sErrored] = true;
eventName = "error";
} else if (this[sTimedOut]) {
eventName = "timeout";
}
this[sDispatch](new CustomEvent(eventName));
}
)
.finally(() => this[sDispatch](new CustomEvent("loadend")));
}
get upload(): XMLHttpRequestUpload {
return new XMLHttpRequestUpload();
}
}
// interface XMLHttpRequestBodyInit {
// [key: string]: any;
// }
// declare global {
// // Make XMLHttpRequestShim available globally as XMLHttpRequest
// interface Window {
// XMLHttpRequest: typeof XMLHttpRequestShim;
// }
// }
// Export the class for direct imports
export { XMLHttpRequestShim };
// Make it globally available
(globalThis || self).XMLHttpRequest = XMLHttpRequestShim;
// // I think this is code for mjs and cjs support, but I'm not sure
// if (typeof module === "object" && module.exports) {
// module.exports = XMLHttpRequestShim;
// } else {
// (globalThis || self).XMLHttpRequest = XMLHttpRequestShim;
// }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment