Skip to main content
Module

x/xhr/mod.ts

An XMLHttpRequest polyfill for Deno CLI and Deploy 🦕
Very Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
// Copyright 2021 Kitson P. Kelly. All rights reserved. MIT License.
// deno-lint-ignore-file no-explicit-any
import { charset, contentType,} from "https://deno.land/x/media_types@v2.9.0/mod.ts";
type XMLHttpRequestResponseType = | "" | "arraybuffer" | "blob" | "document" | "json" | "text";
function assert(cond: unknown, msg = "assertion failed"): asserts cond { if (!cond) { const err = new Error(msg); err.name = "AssertionError"; throw err; }}
function extractLength(response: Response) { const values = response.headers.get("content-length")?.split(/\s*,\s*/) ?? []; let candidateValue: string | null = null; for (const value of values) { if (candidateValue == null) { candidateValue = value; } else if (value !== candidateValue) { throw new Error("invalid content-length"); } } if (candidateValue == "" || candidateValue == null) { return null; } const v = parseInt(candidateValue, 10); return Number.isNaN(v) ? null : v;}
function getEssence(value: string) { return value.split(/\s*;\s*/)[0];}
function extractMIMEType(headers: Headers) { let mimeType: string | null = null; const values = headers.get("content-type")?.split(/\s*,\s*/); if (!values) { throw new Error("missing content type"); } for (const value of values) { const temporaryMimeType = contentType(value); if (!temporaryMimeType || getEssence(temporaryMimeType) === "*/*") { continue; } mimeType = temporaryMimeType; } if (mimeType == null) { throw new Error("missing content type"); } return mimeType;}
function isHTMLMIMEType(value: string) { return getEssence(value) === "text/html";}
function isXMLMIMEType(value: string) { const essence = getEssence(value); return essence.endsWith("+xml") || essence === "text/xml" || essence === "application/xml";}
const decoder = new TextDecoder();
function parseJSONFromBytes(value: Uint8Array): any { const string = decoder.decode(value); return JSON.parse(string);}
function appendBytes(...bytes: Uint8Array[]): Uint8Array { let length = 0; for (const b of bytes) { length += b.length; } const result = new Uint8Array(length); let offset = 0; for (const b of bytes) { result.set(b, offset); offset += b.length; } return result;}
class XMLHttpRequestEventTarget extends EventTarget { onabort: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; onerror: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; onload: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; onloadend: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; onloadstart: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; onprogress: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null; ontimeout: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null = null;
dispatchEvent(evt: Event) { if (evt instanceof ProgressEvent) { const xhr: XMLHttpRequest = this as any; switch (evt.type) { case "abort": if (this.onabort) { this.onabort.call(xhr, evt); } break; case "error": if (this.onerror) { this.onerror.call(xhr, evt); } break; case "load": if (this.onload) { this.onload.call(xhr, evt); } break; case "loadend": if (this.onloadend) { this.onloadend.call(xhr, evt); } break; case "loadstart": if (this.onloadstart) { this.onloadstart.call(xhr, evt); } break; case "progress": if (this.onprogress) { this.onprogress.call(xhr, evt); } break; case "timeout": if (this.ontimeout) { this.ontimeout.call(xhr, evt); } } } if (evt.cancelable && evt.defaultPrevented) { return false; } else { return super.dispatchEvent(evt); } }}
class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {}
enum State { UNSENT = 0, OPENED = 1, HEADERS_RECEIVED = 2, LOADING = 3, DONE = 4,}
const METHODS = ["GET", "HEAD", "POST", "DELETE", "OPTIONS", "PUT"];
class XMLHttpRequest extends XMLHttpRequestEventTarget { #abortedFlag = false; #abortController?: AbortController; #crossOriginCredentials = false; #headers = new Headers(); #mime?: string; #receivedBytes = new Uint8Array(); #requestMethod?: string; #response?: Response; #responseObject: any = null; #responseType: XMLHttpRequestResponseType = ""; #sendFlag = false; #state = State.UNSENT; #timedoutFlag = false; #timeout = 0; #upload = new XMLHttpRequestUpload(); #uploadCompleteFlag = false; #uploadListener = false; #url?: URL;
#getResponseMIMEType() { try { assert(this.#response); const mimeType = extractMIMEType(this.#response.headers); return mimeType; } catch { return "text/xml"; } }
#getFinalMIMEType() { if (!this.#mime) { return this.#getResponseMIMEType(); } else { return this.#mime; } }
#getFinalEncoding() { return charset(this.#getFinalMIMEType())?.toLocaleLowerCase() ?? null; }
#getTextResponse() { if (this.#response?.body == null) { return ""; } let charset = this.#getFinalEncoding(); if ( this.#responseType === "" && charset == null && isXMLMIMEType(this.#getFinalMIMEType()) ) { charset = "utf-8"; } charset = charset ?? "utf8"; const decoder = new TextDecoder(charset); return decoder.decode(this.#receivedBytes); }
#handleResponseEndOfBody() { assert(this.#response); const loaded = this.#receivedBytes.length; const total = extractLength(this.#response) ?? 0; this.dispatchEvent(new ProgressEvent("progress", { loaded, total })); this.#state = State.DONE; this.#sendFlag = false; this.dispatchEvent(new Event("readystatechange")); this.dispatchEvent(new ProgressEvent("load", { loaded, total })); this.dispatchEvent(new ProgressEvent("loadend", { loaded, total })); }
#handleErrors() { if (!this.#sendFlag) { return; } if (this.#timedoutFlag) { this.#requestErrorSteps("timeout"); } else if (this.#abortedFlag) { this.#requestErrorSteps("abort"); } else { this.#requestErrorSteps("error"); } }
#requestErrorSteps(event: string) { this.#state = State.DONE; this.#sendFlag = false; this.dispatchEvent(new Event("readystatechange")); if (!this.#uploadCompleteFlag) { this.#uploadCompleteFlag = true; if (this.#uploadListener) { this.#upload.dispatchEvent( new ProgressEvent(event, { loaded: 0, total: 0 }), ); this.#upload.dispatchEvent( new ProgressEvent("loadend", { loaded: 0, total: 0 }), ); } } this.dispatchEvent(new ProgressEvent(event, { loaded: 0, total: 0 })); this.dispatchEvent(new ProgressEvent("loadend", { loaded: 0, total: 0 })); }
#setDocumentResponse() { assert(this.#response); if (this.#response.body == null) { return; } const finalMIME = this.#getFinalMIMEType(); if (!(isHTMLMIMEType(finalMIME) || isXMLMIMEType(finalMIME))) { return; } if (this.#responseType === "" && isHTMLMIMEType(finalMIME)) { return; } this.#responseObject = new DOMException( "Document bodies are not supported", "SyntaxError", ); }
#terminate() { if (this.#abortController) { this.#abortController.abort(); this.#abortController = undefined; } }
onreadystatechange: ((this: XMLHttpRequest, ev: Event) => any) | null = null;
get readyState(): number { return this.#state; }
get response(): any { if (this.#responseType === "" || this.#responseType === "text") { if (!(this.#state === State.LOADING || this.#state === State.DONE)) { return ""; } return this.#getTextResponse(); } if (this.#state !== State.DONE) { return null; } if (this.#responseObject instanceof Error) { return null; } if (this.#responseObject != null) { return this.#responseObject; } if (this.#responseType === "arraybuffer") { try { this.#responseObject = this.#receivedBytes.buffer.slice( this.#receivedBytes.byteOffset, this.#receivedBytes.byteLength + this.#receivedBytes.byteOffset, ); } catch (e) { this.#responseObject = e; return null; } } else if (this.#responseType === "blob") { this.#responseObject = new Blob([this.#receivedBytes], { type: this.#getFinalMIMEType(), }); } else if (this.#responseType === "document") { this.#setDocumentResponse(); } else { assert(this.#responseType === "json"); if (this.#response?.body == null) { return null; } let jsonObject; try { jsonObject = parseJSONFromBytes(this.#receivedBytes); } catch { return null; } this.#responseObject = jsonObject; } return this.#responseObject instanceof Error ? null : this.#responseObject; }
get responseText(): string { if (!(this.#responseType === "" || this.#responseType === "text")) { throw new DOMException( "Response type is not set properly", "InvalidStateError", ); } if (!(this.#state === State.LOADING || this.#state === State.DONE)) { return ""; } return this.#getTextResponse(); }
get responseType(): XMLHttpRequestResponseType { return this.#responseType; }
set responseType(value: XMLHttpRequestResponseType) { if (value === "document") { return; } if (this.#state === State.LOADING || this.#state === State.DONE) { throw new DOMException( "The response type cannot be changed when loading or done", "InvalidStateError", ); } this.#responseType = value; }
get responseURL(): string { return this.#response?.url ?? ""; }
get responseXML(): null { if (!(this.#responseType === "" || this.#responseType === "document")) { throw new DOMException( "Response type is not properly set", "InvalidStateError", ); } if (this.#state !== State.DONE) { return null; } if (this.#setDocumentResponse instanceof Error) { return null; } this.#setDocumentResponse(); return null; }
get status(): number { return this.#response?.status ?? 0; }
get statusText(): string { return this.#response?.statusText ?? ""; }
get timeout(): number { return this.#timeout; }
set timeout(value: number) { this.#timeout = value; }
get upload(): XMLHttpRequestUpload { return this.#upload; }
get withCredentials(): boolean { return this.#crossOriginCredentials; }
set withCredentials(value: boolean) { if ( !(this.#state === State.UNSENT || this.#state === State.OPENED) ) { throw new DOMException( "The request is not unsent or opened", "InvalidStateError", ); } if (this.#sendFlag) { throw new DOMException("The request has been sent", "InvalidStateError"); } this.#crossOriginCredentials = value; }
abort(): void { this.#terminate(); if ( (this.#state === State.OPENED && this.#sendFlag) || this.#state === State.HEADERS_RECEIVED || this.#state === State.LOADING ) { this.#requestErrorSteps("abort"); } if (this.#state === State.DONE) { this.#state = State.UNSENT; this.#response = undefined; } }
dispatchEvent(evt: Event) { switch (evt.type) { case "readystatechange": if (this.onreadystatechange) { this.onreadystatechange.call(this, evt); } break; } if (evt.cancelable && evt.defaultPrevented) { return false; } else { return super.dispatchEvent(evt); } }
getAllResponseHeaders(): string | null { if (!this.#response) { return null; } const headers = [...this.#response.headers]; headers.sort(([a], [b]) => a.localeCompare(b)); return headers.map(([key, value]) => `${key}: ${value}`).join("\r\n"); }
getResponseHeader(name: string): string | null { return this.#response?.headers.get(name) ?? null; }
open( method: string, url: string, async = true, username: string | null = null, password: string | null = null, ): void { method = method.toLocaleUpperCase(); if (!METHODS.includes(method)) { throw new DOMException( `The method "${method}" is not allowed.`, "SyntaxError", ); } let parsedUrl: URL; try { let base: string | undefined; try { base = window.location.toString(); } catch { // we just want to avoid the error about location in Deno } parsedUrl = new URL(url, base); } catch { throw new DOMException(`The url "${url}" is invalid.`, "SyntaxError"); } if (username != null) { parsedUrl.username = username; } if (password != null) { parsedUrl.password = password; } if (async === false) { throw new DOMException( "The polyfill does not support sync operation.", "InvalidAccessError", ); } this.#terminate(); this.#sendFlag = false; this.#uploadListener = false; this.#requestMethod = method; this.#url = parsedUrl; this.#headers = new Headers(); this.#response = undefined; this.#state = State.OPENED; this.dispatchEvent(new Event("readystatechange")); }
overrideMimeType(mime: string): void { if (this.#state === State.LOADING || this.#state === State.DONE) { throw new DOMException( "The request is in an invalid state", "InvalidStateError", ); } this.#mime = contentType(mime) ?? "application/octet-stream"; }
send(body: BodyInit | null = null): void { if (this.#state !== State.OPENED) { throw new DOMException("Invalid state", "InvalidStateError"); } if (this.#sendFlag) { throw new DOMException("Invalid state", "InvalidStateError"); } if (this.#requestMethod === "GET" || this.#requestMethod === "HEAD") { body = null; } const abortController = this.#abortController = new AbortController(); const req = new Request(this.#url!.toString(), { method: this.#requestMethod, headers: this.#headers, body, mode: "cors", credentials: this.#crossOriginCredentials ? "include" : "same-origin", signal: abortController.signal, }); this.#uploadCompleteFlag = false; this.#timedoutFlag = false; if (req.body == null) { this.#uploadCompleteFlag = true; } this.#sendFlag = true;
this.dispatchEvent(new ProgressEvent("loadstart", { loaded: 0, total: 0 })); this.#upload.dispatchEvent( new ProgressEvent("loadstart", { loaded: 0, total: 0 }), ); if (this.#state !== State.OPENED || !this.#sendFlag) { return; } const processRequestEndOfBody = () => { this.#uploadCompleteFlag = true; if (!this.#uploadListener) { return; } this.#upload.dispatchEvent( new ProgressEvent("progress", { loaded: 0, total: 0 }), ); this.#upload.dispatchEvent( new ProgressEvent("load", { loaded: 0, total: 0, }), ); this.#upload.dispatchEvent( new ProgressEvent("loadend", { loaded: 0, total: 0 }), ); }; const processResponse = async (response: Response) => { this.#response = response; this.#state = State.HEADERS_RECEIVED; this.dispatchEvent(new Event("readystatechange")); if (this.#state !== State.HEADERS_RECEIVED) { return; } if (response.body == null) { this.#handleResponseEndOfBody(); return; } const total = extractLength(this.#response) ?? 0; let lastInvoked = 0; const processBodyChunk = (bytes: Uint8Array) => { this.#receivedBytes = appendBytes(this.#receivedBytes, bytes); if ((Date.now() - lastInvoked) <= 50) { return; } lastInvoked = Date.now(); if (this.#state === State.HEADERS_RECEIVED) { this.#state = State.LOADING; } this.dispatchEvent(new Event("readystatechange")); this.dispatchEvent( new ProgressEvent("progress", { loaded: this.#receivedBytes.length, total, }), ); }; const processEndOfBody = () => { this.#handleResponseEndOfBody(); }; const processBodyError = () => { this.#handleErrors(); }; try { for await (const bytes of response.body) { processBodyChunk(bytes); } processEndOfBody(); } catch { processBodyError(); } }; const processRejection = () => { this.#handleErrors(); }; const p = fetch(req).then((response) => { processRequestEndOfBody(); return processResponse(response); }).catch(processRejection); if (this.#timeout > 0) { const t = new Promise<boolean>((res) => { setTimeout(() => res(true), this.#timeout); }); Promise.race([p, t]).then((value) => { if (value) { this.#timedoutFlag = true; this.#terminate(); } }); } }
setRequestHeader(name: string, value: string): void { if (this.#state !== State.OPENED) { throw new DOMException("Invalid state", "InvalidStateError"); } if (this.#sendFlag) { throw new DOMException("Invalid state", "InvalidateStateError"); } this.#headers.append(name, value); }
get DONE() { return State.DONE; }
get HEADERS_RECEIVED() { return State.HEADERS_RECEIVED; }
get LOADING() { return State.LOADING; }
get OPENED() { return State.OPENED; }
get UNSENT() { return State.UNSENT; }
static get DONE() { return State.DONE; }
static get HEADERS_RECEIVED() { return State.HEADERS_RECEIVED; }
static get LOADING() { return State.LOADING; }
static get OPENED() { return State.OPENED; }
static get UNSENT() { return State.UNSENT; }}
// deno-lint-ignore ban-typesfunction maybeDefine(value: Function, scope: object) { const name = value.name; if (!(name in globalThis)) { Object.defineProperty(scope, name, { value, writable: true, configurable: true, enumerable: false, }); }}
maybeDefine(XMLHttpRequest, globalThis);maybeDefine(XMLHttpRequestEventTarget, globalThis);maybeDefine(XMLHttpRequestUpload, globalThis);