Skip to content

Commit

Permalink
Introduce a new resource loading API
Browse files Browse the repository at this point in the history
This is a first draft at the new resource loading API discussed in #2050. For now the old resource loading API, and the old API in general, are preserved; they will be removed in subsequent commits.

This also raises the minimum required version to Node.js v8, as the new resource loader will be part of a major release anyway, and the maintenance burden of supporting that old vm module is getting too high.
  • Loading branch information
sarvaje authored and domenic committed Aug 13, 2018
1 parent 5bdde40 commit 1c792e1
Show file tree
Hide file tree
Showing 30 changed files with 1,300 additions and 522 deletions.
10 changes: 5 additions & 5 deletions .travis.yml
@@ -1,7 +1,7 @@
language: node_js
node_js:
- 6
- 8
- "8"
- "10"
sudo: false

git:
Expand Down Expand Up @@ -32,7 +32,7 @@ addons:

matrix:
include:
- node_js: 6
- node_js: 8
env: TEST_SUITE=node-canvas-prebuilt
script: "export CXX=g++-4.8 && yarn add canvas-prebuilt && yarn test --retries 1"
addons:
Expand All @@ -49,7 +49,7 @@ matrix:
- www.not-web-platform.test
- www.web-platform.test
- xn--n8j6ds53lwwkrqhv28a.web-platform.test
- node_js: 6
- node_js: 8
env: TEST_SUITE=node-canvas
script: "export CXX=g++-4.8 && yarn add canvas && yarn test --retries 1"
addons:
Expand All @@ -76,7 +76,7 @@ matrix:
- libpango1.0-dev
- libgif-dev
- build-essential
- node_js: 8
- node_js: 10
env: TEST_SUITE=node-canvas
script: "export CXX=g++-4.8 && yarn add canvas && yarn test --retries 1"
addons:
Expand Down
42 changes: 40 additions & 2 deletions lib/api.js
Expand Up @@ -14,8 +14,9 @@ const VirtualConsole = require("./jsdom/virtual-console.js");
const Window = require("./jsdom/browser/Window.js");
const { domToHtml } = require("./jsdom/browser/domtohtml.js");
const { applyDocumentFeatures } = require("./jsdom/browser/documentfeatures.js");
const { wrapCookieJarForRequest } = require("./jsdom/browser/resource-loader.js");
const wrapCookieJarForRequest = require("./jsdom/living/helpers/wrap-cookie-jar-for-request");
const { version: packageVersion } = require("../package.json");
const ResourceLoader = require("./jsdom/browser/resources/resource-loader");

const DEFAULT_USER_AGENT = `Mozilla/5.0 (${process.platform}) AppleWebKit/537.36 (KHTML, like Gecko) ` +
`jsdom/${packageVersion}`;
Expand All @@ -36,6 +37,8 @@ let sharedFragmentDocument = null;
class JSDOM {
constructor(input, options = {}) {
const { html, encoding } = normalizeHTML(input, options[transportLayerEncodingLabelHiddenOption]);

options = setGlobalDefaultConfig(options);
options = transformOptions(options, encoding);

this[window] = new Window(options.windowOptions);
Expand Down Expand Up @@ -138,6 +141,8 @@ class JSDOM {
url = parsedURL.href;
options = normalizeFromURLOptions(options);

options = setGlobalDefaultConfig(options);

const requestOptions = {
resolveWithFullResponse: true,
encoding: null, // i.e., give me the raw Buffer
Expand All @@ -148,7 +153,11 @@ class JSDOM {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en"
},
jar: wrapCookieJarForRequest(options.cookieJar)
jar: wrapCookieJarForRequest(options.cookieJar),
agentOptions: options.agentOptions,
pool: options.pool,
strictSSL: options.strictSSL,
proxy: options.proxy
};

return request(url, requestOptions).then(res => {
Expand Down Expand Up @@ -228,6 +237,24 @@ function normalizeFromFileOptions(filename, options) {
return normalized;
}

function setGlobalDefaultConfig(config) {
const resultConfig = Object.assign({}, config);

resultConfig.parseOptions = { locationInfo: true };

resultConfig.pool = resultConfig.pool !== undefined ? resultConfig.pool : { maxSockets: 6 };

resultConfig.agentOptions = resultConfig.agentOptions !== undefined ?
resultConfig.agentOptions :
{ keepAlive: true, keepAliveMsecs: 115 * 1000 };

resultConfig.strictSSL = resultConfig.strictSSL !== undefined ? resultConfig.strictSSL : true;

resultConfig.userAgent = resultConfig.userAgent || DEFAULT_USER_AGENT;

return resultConfig;
}

function transformOptions(options, encoding) {
const transformed = {
windowOptions: {
Expand All @@ -243,7 +270,14 @@ function transformOptions(options, encoding) {
pretendToBeVisual: false,
storageQuota: 5000000,

// Request
pool: options.pool,
agentOptions: options.agentOptions,
strictSSL: options.strictSSL,
proxy: options.proxy,

// Defaults filled in later
resourceLoader: undefined,
virtualConsole: undefined,
cookieJar: undefined
},
Expand Down Expand Up @@ -292,6 +326,10 @@ function transformOptions(options, encoding) {
(new VirtualConsole()).sendTo(console) :
options.virtualConsole;

transformed.windowOptions.resourceLoader = options.resourceLoader === undefined ?
new ResourceLoader() :
options.resourceLoader;

if (options.resources !== undefined) {
transformed.resources = String(options.resources);
if (transformed.resources !== "usable") {
Expand Down
8 changes: 7 additions & 1 deletion lib/jsdom/browser/Window.js
Expand Up @@ -30,6 +30,7 @@ const reportException = require("../living/helpers/runtime-script-errors");
const { matchesDontThrow } = require("../living/helpers/selectors");
const SessionHistory = require("../living/window/SessionHistory");
const { contextifyWindow } = require("./documentfeatures.js");
const ResourceLoader = require("./resources/resource-loader");

const GlobalEventHandlersImpl = require("../living/nodes/GlobalEventHandlers-impl").implementation;
const WindowEventHandlersImpl = require("../living/nodes/WindowEventHandlers-impl").implementation;
Expand Down Expand Up @@ -76,6 +77,12 @@ function Window(options) {

///// PRIVATE DATA PROPERTIES

if (!(options.resourceLoader instanceof ResourceLoader)) {
throw new RangeError("options.resourceLoader must implement ResourceLoader");
}

this._resourceLoader = options.resourceLoader;

// vm initialization is deferred until script processing is activated
this._globalProxy = this;
Object.defineProperty(idlUtils.implForWrapper(this), idlUtils.wrapperSymbol, { get: () => this._globalProxy });
Expand All @@ -95,7 +102,6 @@ function Window(options) {
referrer: options.referrer,
cookie: options.cookie,
deferClose: options.deferClose,
resourceLoader: options.resourceLoader,
concurrentNodeIterators: options.concurrentNodeIterators,
pool: options.pool,
agent: options.agent,
Expand Down
File renamed without changes.
78 changes: 78 additions & 0 deletions lib/jsdom/browser/resources/async-resource-queue.js
@@ -0,0 +1,78 @@
"use strict";

/**
* AsyncResourceQueue is the queue in charge of run the async scripts
* and notify when they finish.
*/
module.exports = class AsyncResourceQueue {
constructor() {
this.items = new Set();
}

count() {
return this.items.size;
}

_notify() {
if (this._listener) {
this._listener();
}
}

setListener(listener) {
this._listener = listener;
}

push(request, onLoad, onError) {
const q = this;

q.items.add(request);

function check(error, data) {
let promise;

if (onError && error) {
promise = onError(error);
} else if (onLoad && data) {
promise = onLoad(data);
}

promise
.then(() => {
q.items.delete(request);

if (q.count() === 0) {
q._notify();
}
});
}

return request
.then(data => {
if (onLoad) {
return check(null, data);
}

q.items.delete(request);

if (q.count() === 0) {
q._notify();
}

return null;
})
.catch(err => {
if (onError) {
return check(err);
}

q.items.delete(request);

if (q.count() === 0) {
q._notify();
}

return null;
});
}
};
116 changes: 116 additions & 0 deletions lib/jsdom/browser/resources/per-document-resource-loader.js
@@ -0,0 +1,116 @@
"use strict";
const wrapCookieJarForRequest = require("../../living/helpers/wrap-cookie-jar-for-request");

module.exports = class PerDocumentResourceLoader {
constructor(document) {
this._document = document;
this._defaultEncoding = document._encoding;
this._resourceLoader = document._defaultView ? document._defaultView._resourceLoader : null;
this._requestManager = document._requestManager;
this._queue = document._queue;
this._deferQueue = document._deferQueue;
this._asyncQueue = document._asyncQueue;

// TODO: Remove when _hasFeature is no longer necessary
this._implementation = document._implementation;
}

fetch(url, options) {
const { element } = options;

if (!this._implementation._hasFeature("FetchExternalResources", element.tagName.toLowerCase())) {
return null;
}

if (this._implementation._hasFeature("SkipExternalResources", url)) {
return null;
}

const requestOptions = {
jar: wrapCookieJarForRequest(this._document._cookieJar),
proxy: this._document._proxy,
strictSSL: typeof this._document._strictSSL === "boolean" ? this._document._strictSSL : true
};

options = Object.assign({}, options, { requestOptions, referrer: this._document.URL });

const request = this._resourceLoader.fetch(
url,
options
);

this._requestManager.add(request);

const ev = this._document.createEvent("HTMLEvents");

const onError = error => {
this._requestManager.remove(request);

if (options.onError) {
options.onError(error);
}

// TODO: Create events in the modern way https://github.com/jsdom/jsdom/pull/2279#discussion_r199969734
ev.initEvent("error", false, false);
element.dispatchEvent(ev);

const err = new Error(`Could not load ${element.localName}: "${url}"`);
err.type = "resource loading";
err.detail = error;

this._document._defaultView._virtualConsole.emit("jsdomError", err);

return Promise.resolve();
};

const onLoad = data => {
this._requestManager.remove(request);

this._addCookies(url, request.response ? request.response.headers : {});

try {
const result = options.onLoad ? options.onLoad(data) : undefined;

return Promise.resolve(result)
.then(() => {
// TODO: Create events in the modern way https://github.com/jsdom/jsdom/pull/2279#discussion_r199969734
ev.initEvent("load", false, false);
element.dispatchEvent(ev);

return Promise.resolve();
})
.catch(err => {
return onError(err);
});
} catch (err) {
return onError(err);
}
};

if (element.localName === "script" && element.hasAttribute("async")) {
this._asyncQueue.push(request, onLoad, onError);
} else if (element.localName === "script" && element.hasAttribute("defer")) {
this._deferQueue.push(request, onLoad, onError, false, element);
} else {
this._queue.push(request, onLoad, onError, false, element);
}

return request;
}

_addCookies(url, headers) {
let cookies = headers["set-cookie"];

if (!cookies) {
return;
}

if (!Array.isArray(cookies)) {
cookies = [cookies];
}

cookies.forEach(cookie => {
this._document._cookieJar.setCookieSync(cookie, url, { http: true, ignoreError: true });
});
}
};
33 changes: 33 additions & 0 deletions lib/jsdom/browser/resources/request-manager.js
@@ -0,0 +1,33 @@
"use strict";

/**
* Manage all the request and it is able to abort
* all pending request.
*/
module.exports = class RequestManager {
constructor() {
this.openedRequests = [];
}

add(req) {
this.openedRequests.push(req);
}

remove(req) {
const idx = this.openedRequests.indexOf(req);
if (idx !== -1) {
this.openedRequests.splice(idx, 1);
}
}

close() {
for (const openedRequest of this.openedRequests) {
openedRequest.abort();
}
this.openedRequests = [];
}

size() {
return this.openedRequests.length;
}
};

0 comments on commit 1c792e1

Please sign in to comment.