"use strict"; const parseContentType = require("content-type-parser"); const sniffHTMLEncoding = require("html-encoding-sniffer"); const whatwgEncoding = require("whatwg-encoding"); const parseDataUrl = require("../utils").parseDataUrl; const fs = require("fs"); const request = require("request"); const documentBaseURLSerialized = require("../living/helpers/document-base-url").documentBaseURLSerialized; const NODE_TYPE = require("../living/node-type"); /* eslint-disable no-restricted-modules */ // TODO: stop using the built-in URL in favor of the spec-compliant whatwg-url package // This legacy usage is in the process of being purged. const URL = require("url"); /* eslint-enable no-restricted-modules */ const IS_BROWSER = Object.prototype.toString.call(process) !== "[object process]"; function createResourceLoadHandler(element, resourceUrl, document, loadCallback) { if (loadCallback === undefined) { loadCallback = () => { // do nothing }; } return (err, data, response) => { const ev = document.createEvent("HTMLEvents"); if (!err) { try { loadCallback.call(element, data, resourceUrl, response); ev.initEvent("load", false, false); } catch (e) { err = e; } } if (err) { if (!err.isAbortError) { ev.initEvent("error", false, false); ev.error = err; element.dispatchEvent(ev); const error = new Error(`Could not load ${element.localName}: "${resourceUrl}"`); error.detail = err; error.type = "resource loading"; document._defaultView._virtualConsole.emit("jsdomError", error); } } else { element.dispatchEvent(ev); } }; } exports.readFile = function (filePath, options, callback) { const readableStream = fs.createReadStream(filePath); let data = new Buffer(0); readableStream.on("error", callback); readableStream.on("data", chunk => { data = Buffer.concat([data, chunk]); }); const defaultEncoding = options.defaultEncoding; const detectMetaCharset = options.detectMetaCharset; readableStream.on("end", () => { // Not passing default encoding means binary if (defaultEncoding) { const encoding = detectMetaCharset ? sniffHTMLEncoding(data, { defaultEncoding }) : whatwgEncoding.getBOMEncoding(data) || defaultEncoding; const decoded = whatwgEncoding.decode(data, encoding); callback(null, decoded, { headers: { "content-type": "text/plain;charset=" + encoding } }); } else { callback(null, data); } }); return { abort() { readableStream.destroy(); const error = new Error("request canceled by user"); error.isAbortError = true; callback(error); } }; }; function readDataUrl(dataUrl, options, callback) { const defaultEncoding = options.defaultEncoding; try { const data = parseDataUrl(dataUrl); // If default encoding does not exist, pass on binary data. if (defaultEncoding) { const contentType = parseContentType(data.type) || parseContentType("text/plain"); const sniffOptions = { transportLayerEncodingLabel: contentType.get("charset"), defaultEncoding }; const encoding = options.detectMetaCharset ? sniffHTMLEncoding(data.buffer, sniffOptions) : whatwgEncoding.getBOMEncoding(data.buffer) || whatwgEncoding.labelToName(contentType.get("charset")) || defaultEncoding; const decoded = whatwgEncoding.decode(data.buffer, encoding); contentType.set("charset", encoding); data.type = contentType.toString(); callback(null, decoded, { headers: { "content-type": data.type } }); } else { callback(null, data.buffer, { headers: { "content-type": data.type } }); } } catch (err) { callback(err, null); } return null; } // NOTE: request wraps tough-cookie cookie jar // (see: https://github.com/request/request/blob/master/lib/cookies.js). // Therefore, to pass our cookie jar to the request, we need to create // request's wrapper and monkey patch it with our jar. function wrapCookieJarForRequest(cookieJar) { const jarWrapper = request.jar(); jarWrapper._jar = cookieJar; return jarWrapper; } function fetch(urlObj, options, callback) { if (urlObj.protocol === "data:") { return readDataUrl(urlObj.href, options, callback); } else if (urlObj.hostname) { return exports.download(urlObj, options, callback); } const filePath = urlObj.pathname .replace(/^file:\/\//, "") .replace(/^\/([a-z]):\//i, "$1:/") .replace(/%20/g, " "); return exports.readFile(filePath, options, callback); } exports.enqueue = function (element, resourceUrl, callback) { const document = element.nodeType === NODE_TYPE.DOCUMENT_NODE ? element : element._ownerDocument; if (document._queue) { const loadHandler = createResourceLoadHandler(element, resourceUrl || document.URL, document, callback); return document._queue.push(loadHandler); } return () => { // do nothing in queue-less documents }; }; exports.download = function (url, options, callback) { const requestOptions = { pool: options.pool, agent: options.agent, agentOptions: options.agentOptions, agentClass: options.agentClass, strictSSL: options.strictSSL, gzip: true, jar: wrapCookieJarForRequest(options.cookieJar), encoding: null, headers: { "User-Agent": options.userAgent, "Accept-Language": "en", Accept: options.accept || "*/*" } }; if (options.referrer && !IS_BROWSER) { requestOptions.headers.referer = options.referrer; } if (options.proxy) { requestOptions.proxy = options.proxy; } Object.assign(requestOptions.headers, options.headers); const defaultEncoding = options.defaultEncoding; const detectMetaCharset = options.detectMetaCharset; const req = request(url, requestOptions, (error, response, bufferData) => { if (!error) { // If default encoding does not exist, pass on binary data. if (defaultEncoding) { const contentType = parseContentType(response.headers["content-type"]) || parseContentType("text/plain"); const sniffOptions = { transportLayerEncodingLabel: contentType.get("charset"), defaultEncoding }; const encoding = detectMetaCharset ? sniffHTMLEncoding(bufferData, sniffOptions) : whatwgEncoding.getBOMEncoding(bufferData) || whatwgEncoding.labelToName(contentType.get("charset")) || defaultEncoding; const decoded = whatwgEncoding.decode(bufferData, encoding); contentType.set("charset", encoding); response.headers["content-type"] = contentType.toString(); callback(null, decoded, response); } else { callback(null, bufferData, response); } } else { callback(error, null, response); } }); return { abort() { req.abort(); const error = new Error("request canceled by user"); error.isAbortError = true; callback(error); } }; }; exports.load = function (element, urlString, options, callback) { const document = element._ownerDocument; const documentImpl = document.implementation; if (!documentImpl._hasFeature("FetchExternalResources", element.tagName.toLowerCase())) { return; } if (documentImpl._hasFeature("SkipExternalResources", urlString)) { return; } const urlObj = URL.parse(urlString); const enqueued = exports.enqueue(element, urlString, callback); const customLoader = document._customResourceLoader; const requestManager = document._requestManager; const cookieJar = document._cookieJar; options.accept = element._accept; options.cookieJar = cookieJar; options.referrer = document.URL; options.pool = document._pool; options.agentOptions = document._agentOptions; options.strictSSL = document._strictSSL; options.proxy = document._proxy; options.userAgent = document._defaultView.navigator.userAgent; let req = null; function wrappedEnqueued() { if (req && requestManager) { requestManager.remove(req); } // do not trigger if the window is closed if (element._ownerDocument && element._ownerDocument.defaultView.document) { enqueued.apply(this, arguments); } } if (typeof customLoader === "function") { req = customLoader({ element, url: urlObj, cookie: cookieJar.getCookieStringSync(urlObj, { http: true }), baseUrl: documentBaseURLSerialized(document), defaultFetch(fetchCallback) { return fetch(urlObj, options, fetchCallback); } }, wrappedEnqueued); } else { req = fetch(urlObj, options, wrappedEnqueued); } if (req && requestManager) { requestManager.add(req); } };