From b1b7f230132806617c53b076f521b4b0ab854226 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Sat, 20 May 2017 09:01:59 -0400 Subject: [PATCH] Refactor XHR's send() implementation This should improve correctness slightly, but it was mainly motivated by trying to fix a bug that in the end was not related to this code. Even though it did not fix the bug I was chasing, it was a good refactoring, so I thought I'd best commit it anyway. --- lib/jsdom/living/xmlhttprequest.js | 178 ++++++++++++++++++----------- 1 file changed, 112 insertions(+), 66 deletions(-) diff --git a/lib/jsdom/living/xmlhttprequest.js b/lib/jsdom/living/xmlhttprequest.js index 6d12f6d0ae..3884d1d91f 100644 --- a/lib/jsdom/living/xmlhttprequest.js +++ b/lib/jsdom/living/xmlhttprequest.js @@ -6,6 +6,7 @@ const URL = require("whatwg-url").URL; const whatwgEncoding = require("whatwg-encoding"); const tough = require("tough-cookie"); const parseContentType = require("content-type-parser"); +const conversions = require("webidl-conversions"); const xhrUtils = require("./xhr-utils"); const DOMException = require("../web-idl/DOMException"); @@ -15,6 +16,7 @@ const documentBaseURLSerialized = require("./helpers/document-base-url").documen const idlUtils = require("./generated/utils"); const Document = require("./generated/Document"); const Blob = require("./generated/Blob"); +const FormData = require("./generated/FormData"); const domToHtml = require("../browser/domtohtml").domToHtml; const syncWorkerFile = require.resolve ? require.resolve("./xhr-sync-worker.js") : null; @@ -90,7 +92,6 @@ const simpleHeaders = xhrUtils.simpleHeaders; module.exports = function createXMLHttpRequest(window) { const Event = window.Event; const ProgressEvent = window.ProgressEvent; - const FormData = window.FormData; const XMLHttpRequestEventTarget = window.XMLHttpRequestEventTarget; const XMLHttpRequestUpload = window.XMLHttpRequestUpload; @@ -534,9 +535,13 @@ module.exports = function createXMLHttpRequest(window) { } send(body) { + body = coerceBodyArg(body); + + // Not per spec, but per tests: https://github.com/whatwg/xhr/issues/65 if (!this._ownerDocument) { throw new DOMException(DOMException.INVALID_STATE_ERR); } + const flag = this[xhrSymbols.flag]; const properties = this[xhrSymbols.properties]; @@ -547,77 +552,41 @@ module.exports = function createXMLHttpRequest(window) { properties.beforeSend = true; try { - if (!flag.body && - body !== undefined && - body !== null && - body !== "" && - !(flag.method === "HEAD" || flag.method === "GET")) { - let contentType = null; + if (flag.method === "GET" || flag.method === "HEAD") { + body = null; + } + + if (body !== null) { let encoding = null; - if (body instanceof FormData) { - flag.formData = true; - const formData = []; - for (const entry of idlUtils.implForWrapper(body)._entries) { - let val; - if (Blob.isImpl(entry.value)) { - const blob = entry.value; - val = { - name: entry.name, - value: blob._buffer, - options: { - filename: blob.name, - contentType: blob.type, - knownLength: blob.size - } - }; - } else { - val = entry; - } - formData.push(val); - } - flag.body = formData; - // TODO content type; what is the form boundary? - } else if (Blob.is(body)) { - const blob = idlUtils.implForWrapper(body); - flag.body = blob._buffer; - if (blob.type !== "") { - contentType = blob.type; - } - } else if (body instanceof ArrayBuffer) { - flag.body = new Buffer(new Uint8Array(body)); - } else if (ArrayBuffer.isView(body)) { - flag.body = new Buffer(body.buffer, body.byteOffset, body.byteLength); - } else if (body instanceof Document.interface) { - if (body.childNodes.length === 0) { - throw new DOMException(DOMException.INVALID_STATE_ERR); - } - flag.body = domToHtml([body]); + let mimeType = null; + if (Document.isImpl(body)) { encoding = "UTF-8"; - - const documentBodyParsingMode = idlUtils.implForWrapper(body)._parsingMode; - contentType = documentBodyParsingMode === "html" ? "text/html" : "application/xml"; - contentType += ";charset=UTF-8"; - } else if (typeof body !== "string") { - flag.body = String(body); + mimeType = (body._parsingMode === "html" ? "text/html" : "application/xml") + ";charset=UTF-8"; + flag.body = domToHtml([body]); } else { - flag.body = body; - contentType = "text/plain;charset=UTF-8"; - encoding = "UTF-8"; + if (typeof body === "string") { + encoding = "UTF-8"; + } + const { buffer, formData, contentType } = extractBody(body); + mimeType = contentType; + flag.body = buffer || formData; + flag.formData = Boolean(formData); } const existingContentType = xhrUtils.getRequestHeader(flag.requestHeaders, "content-type"); - if (contentType !== null && existingContentType === null) { - flag.requestHeaders["Content-Type"] = contentType; + if (mimeType !== null && existingContentType === null) { + flag.requestHeaders["Content-Type"] = mimeType; } else if (existingContentType !== null && encoding !== null) { const parsed = parseContentType(existingContentType); if (parsed) { - parsed.parameterList - .filter(v => v.key && v.key.toLowerCase() === "charset" && - whatwgEncoding.labelToName(v.value) !== "UTF-8") - .forEach(v => { - v.value = "UTF-8"; - }); + for (const param of parsed.parameterList) { + if (param.key && param.key.toLowerCase() === "charset") { + if (param.value.toLowerCase() !== encoding.toLowerCase()) { + param.value = encoding; + } + } + } xhrUtils.updateRequestHeader(flag.requestHeaders, "content-type", parsed.toString()); } } @@ -630,6 +599,11 @@ module.exports = function createXMLHttpRequest(window) { } } + // request doesn't like zero-length bodies + if (flag.body && flag.body.byteLength === 0) { + flag.body = null; + } + if (flag.synchronous) { const flagStr = JSON.stringify(flag, function (k, v) { if (this === flag && k === "requestManager") { @@ -730,10 +704,7 @@ module.exports = function createXMLHttpRequest(window) { } } }); - if (body !== undefined && - body !== null && - body !== "" && - !(flag.method === "HEAD" || flag.method === "GET")) { + if (body !== null && body !== "") { properties.uploadComplete = false; setDispatchProgressEvents(this); } else { @@ -1098,3 +1069,78 @@ module.exports = function createXMLHttpRequest(window) { return XMLHttpRequest; }; + +function coerceBodyArg(body) { + // Implements the IDL conversion for `optional (Document or BodyInit)? body = null` + + if (body === undefined || body === null) { + return null; + } + + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return body; + } + + const impl = idlUtils.implForWrapper(body); + if (impl) { + // TODO: allow URLSearchParams or ReadableStream + if (Blob.isImpl(impl) || FormData.isImpl(impl) || Document.isImpl(impl)) { + return impl; + } + } + + return conversions.USVString(body); +} + +function extractBody(bodyInit) { + // https://fetch.spec.whatwg.org/#concept-bodyinit-extract + // except we represent the body as a Node.js Buffer instead, + // or a special case for FormData since we want request to handle that. Probably it would be + // cleaner (and allow a future without request) if we did the form encoding ourself. + + if (Blob.isImpl(bodyInit)) { + return { + buffer: bodyInit._buffer, + contentType: bodyInit.type === "" ? null : bodyInit.type + }; + } else if (bodyInit instanceof ArrayBuffer) { + return { + buffer: new Buffer(new Uint8Array(bodyInit)), + contentType: null + }; + } else if (ArrayBuffer.isView(bodyInit)) { + return { + buffer: new Buffer(bodyInit.buffer, bodyInit.byteOffset, bodyInit.byteLength), + contentType: null + }; + } else if (FormData.isImpl(bodyInit)) { + const formData = []; + for (const entry of bodyInit._entries) { + let val; + if (Blob.isImpl(entry.value)) { + const blob = entry.value; + val = { + name: entry.name, + value: blob._buffer, + options: { + filename: blob.name, + contentType: blob.type, + knownLength: blob.size + } + }; + } else { + val = entry; + } + + formData.push(val); + } + + return { formData }; + } + + // Must be a string + return { + buffer: new Buffer(bodyInit, "utf-8"), + contentType: "text/plain;charset=UTF-8" + }; +}