api.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. "use strict";
  2. const path = require("path");
  3. const { pathToFileURL } = require("url");
  4. const fs = require("fs").promises;
  5. const vm = require("vm");
  6. const toughCookie = require("tough-cookie");
  7. const sniffHTMLEncoding = require("html-encoding-sniffer");
  8. const whatwgURL = require("whatwg-url");
  9. const { legacyHookDecode } = require("@exodus/bytes/encoding.js");
  10. const { URL } = require("whatwg-url");
  11. const { MIMEType } = require("whatwg-mimetype");
  12. const { getGlobalDispatcher } = require("undici");
  13. const idlUtils = require("./jsdom/living/generated/utils.js");
  14. const VirtualConsole = require("./jsdom/virtual-console.js");
  15. const { createWindow } = require("./jsdom/browser/Window.js");
  16. const { parseIntoDocument } = require("./jsdom/browser/parser");
  17. const { fragmentSerialization } = require("./jsdom/living/domparsing/serialization.js");
  18. const createDecompressInterceptor = require("./jsdom/browser/resources/decompress-interceptor.js");
  19. const {
  20. JSDOMDispatcher, DEFAULT_USER_AGENT, fetchCollected
  21. } = require("./jsdom/browser/resources/jsdom-dispatcher.js");
  22. const requestInterceptor = require("./jsdom/browser/resources/request-interceptor.js");
  23. class CookieJar extends toughCookie.CookieJar {
  24. constructor(store, options) {
  25. // jsdom cookie jars must be loose by default
  26. super(store, { looseMode: true, ...options });
  27. }
  28. }
  29. const window = Symbol("window");
  30. let sharedFragmentDocument = null;
  31. class JSDOM {
  32. constructor(input = "", options = {}) {
  33. const mimeType = new MIMEType(options.contentType === undefined ? "text/html" : options.contentType);
  34. const { html, encoding } = normalizeHTML(input, mimeType);
  35. options = transformOptions(options, encoding, mimeType);
  36. this[window] = createWindow(options.windowOptions);
  37. const documentImpl = idlUtils.implForWrapper(this[window]._document);
  38. options.beforeParse(this[window]._globalProxy);
  39. parseIntoDocument(html, documentImpl);
  40. documentImpl.close();
  41. }
  42. get window() {
  43. // It's important to grab the global proxy, instead of just the result of `createWindow(...)`, since otherwise
  44. // things like `window.eval` don't exist.
  45. return this[window]._globalProxy;
  46. }
  47. get virtualConsole() {
  48. return this[window]._virtualConsole;
  49. }
  50. get cookieJar() {
  51. // TODO NEWAPI move _cookieJar to window probably
  52. return idlUtils.implForWrapper(this[window]._document)._cookieJar;
  53. }
  54. serialize() {
  55. return fragmentSerialization(idlUtils.implForWrapper(this[window]._document), { requireWellFormed: false });
  56. }
  57. nodeLocation(node) {
  58. if (!idlUtils.implForWrapper(this[window]._document)._parseOptions.sourceCodeLocationInfo) {
  59. throw new Error("Location information was not saved for this jsdom. Use includeNodeLocations during creation.");
  60. }
  61. return idlUtils.implForWrapper(node).sourceCodeLocation;
  62. }
  63. getInternalVMContext() {
  64. if (!vm.isContext(this[window])) {
  65. throw new TypeError("This jsdom was not configured to allow script running. " +
  66. "Use the runScripts option during creation.");
  67. }
  68. return this[window];
  69. }
  70. reconfigure(settings) {
  71. if ("windowTop" in settings) {
  72. this[window]._top = settings.windowTop;
  73. }
  74. if ("url" in settings) {
  75. const document = idlUtils.implForWrapper(this[window]._document);
  76. const url = whatwgURL.parseURL(settings.url);
  77. if (url === null) {
  78. throw new TypeError(`Could not parse "${settings.url}" as a URL`);
  79. }
  80. document._URL = url;
  81. document._origin = whatwgURL.serializeURLOrigin(document._URL);
  82. this[window]._sessionHistory.currentEntry.url = url;
  83. document._clearBaseURLCache();
  84. }
  85. }
  86. static fragment(string = "") {
  87. if (!sharedFragmentDocument) {
  88. sharedFragmentDocument = (new JSDOM()).window.document;
  89. }
  90. const template = sharedFragmentDocument.createElement("template");
  91. template.innerHTML = string;
  92. return template.content;
  93. }
  94. static async fromURL(url, options = {}) {
  95. options = normalizeFromURLOptions(options);
  96. // Build the dispatcher for the initial request
  97. // For the initial fetch, we default to "usable" instead of no resource loading, since fromURL() implicitly requests
  98. // fetching the initial resource. This does not impact further resource fetching, which uses options.resources.
  99. const resourcesForInitialFetch = options.resources !== undefined ? options.resources : "usable";
  100. const { effectiveDispatcher } = extractResourcesOptions(resourcesForInitialFetch, options.cookieJar);
  101. const headers = { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
  102. if (options.referrer) {
  103. headers.Referer = options.referrer;
  104. }
  105. const response = await fetchCollected(effectiveDispatcher, {
  106. url,
  107. headers
  108. });
  109. if (!response.ok) {
  110. throw new Error(`Resource was not loaded. Status: ${response.status}`);
  111. }
  112. options = Object.assign(options, {
  113. url: response.url,
  114. contentType: response.headers["content-type"] || undefined,
  115. referrer: options.referrer,
  116. resources: options.resources
  117. });
  118. return new JSDOM(response.body, options);
  119. }
  120. static async fromFile(filename, options = {}) {
  121. options = normalizeFromFileOptions(filename, options);
  122. const nodeBuffer = await fs.readFile(filename);
  123. return new JSDOM(nodeBuffer, options);
  124. }
  125. }
  126. function normalizeFromURLOptions(options) {
  127. // Checks on options that are invalid for `fromURL`
  128. if (options.url !== undefined) {
  129. throw new TypeError("Cannot supply a url option when using fromURL");
  130. }
  131. if (options.contentType !== undefined) {
  132. throw new TypeError("Cannot supply a contentType option when using fromURL");
  133. }
  134. // Normalization of options which must be done before the rest of the fromURL code can use them, because they are
  135. // given to request()
  136. const normalized = { ...options };
  137. if (options.referrer !== undefined) {
  138. normalized.referrer = (new URL(options.referrer)).href;
  139. }
  140. if (options.cookieJar === undefined) {
  141. normalized.cookieJar = new CookieJar();
  142. }
  143. return normalized;
  144. // All other options don't need to be processed yet, and can be taken care of in the normal course of things when
  145. // `fromURL` calls `new JSDOM(html, options)`.
  146. }
  147. function extractResourcesOptions(resources, cookieJar) {
  148. // loadSubresources controls whether PerDocumentResourceLoader fetches scripts, stylesheets, etc.
  149. // XHR always works regardless of this flag.
  150. let userAgent, baseDispatcher, userInterceptors, loadSubresources;
  151. if (resources === undefined) {
  152. // resources: undefined means no automatic subresource fetching, but XHR still works
  153. userAgent = DEFAULT_USER_AGENT;
  154. baseDispatcher = getGlobalDispatcher();
  155. userInterceptors = [];
  156. loadSubresources = false;
  157. } else if (resources === "usable") {
  158. // resources: "usable" means use all defaults
  159. userAgent = DEFAULT_USER_AGENT;
  160. baseDispatcher = getGlobalDispatcher();
  161. userInterceptors = [];
  162. loadSubresources = true;
  163. } else if (typeof resources === "object" && resources !== null) {
  164. // resources: { userAgent?, dispatcher?, interceptors? }
  165. userAgent = resources.userAgent !== undefined ? resources.userAgent : DEFAULT_USER_AGENT;
  166. baseDispatcher = resources.dispatcher !== undefined ? resources.dispatcher : getGlobalDispatcher();
  167. userInterceptors = resources.interceptors !== undefined ? resources.interceptors : [];
  168. loadSubresources = true;
  169. } else {
  170. throw new TypeError(`resources must be undefined, "usable", or an object`);
  171. }
  172. // User interceptors come first (outermost), then decompress interceptor
  173. const allUserInterceptors = [
  174. ...userInterceptors,
  175. createDecompressInterceptor()
  176. ];
  177. return {
  178. userAgent,
  179. effectiveDispatcher: new JSDOMDispatcher({
  180. baseDispatcher,
  181. cookieJar,
  182. userAgent,
  183. userInterceptors: allUserInterceptors
  184. }),
  185. loadSubresources
  186. };
  187. }
  188. function normalizeFromFileOptions(filename, options) {
  189. const normalized = { ...options };
  190. if (normalized.contentType === undefined) {
  191. const extname = path.extname(filename);
  192. if (extname === ".xhtml" || extname === ".xht" || extname === ".xml") {
  193. normalized.contentType = "application/xhtml+xml";
  194. }
  195. }
  196. if (normalized.url === undefined) {
  197. normalized.url = pathToFileURL(path.resolve(filename)).href;
  198. }
  199. return normalized;
  200. }
  201. function transformOptions(options, encoding, mimeType) {
  202. const transformed = {
  203. windowOptions: {
  204. // Defaults
  205. url: "about:blank",
  206. referrer: "",
  207. contentType: "text/html",
  208. parsingMode: "html",
  209. parseOptions: {
  210. sourceCodeLocationInfo: false,
  211. scriptingEnabled: false
  212. },
  213. runScripts: undefined,
  214. encoding,
  215. pretendToBeVisual: false,
  216. storageQuota: 5000000,
  217. // Defaults filled in later
  218. dispatcher: undefined,
  219. loadSubresources: undefined,
  220. userAgent: undefined,
  221. virtualConsole: undefined,
  222. cookieJar: undefined
  223. },
  224. // Defaults
  225. beforeParse() { }
  226. };
  227. // options.contentType was parsed into mimeType by the caller.
  228. if (!mimeType.isHTML() && !mimeType.isXML()) {
  229. throw new RangeError(`The given content type of "${options.contentType}" was not a HTML or XML content type`);
  230. }
  231. transformed.windowOptions.contentType = mimeType.essence;
  232. transformed.windowOptions.parsingMode = mimeType.isHTML() ? "html" : "xml";
  233. if (options.url !== undefined) {
  234. transformed.windowOptions.url = (new URL(options.url)).href;
  235. }
  236. if (options.referrer !== undefined) {
  237. transformed.windowOptions.referrer = (new URL(options.referrer)).href;
  238. }
  239. if (options.includeNodeLocations) {
  240. if (transformed.windowOptions.parsingMode === "xml") {
  241. throw new TypeError("Cannot set includeNodeLocations to true with an XML content type");
  242. }
  243. transformed.windowOptions.parseOptions = { sourceCodeLocationInfo: true };
  244. }
  245. transformed.windowOptions.cookieJar = options.cookieJar === undefined ?
  246. new CookieJar() :
  247. options.cookieJar;
  248. transformed.windowOptions.virtualConsole = options.virtualConsole === undefined ?
  249. (new VirtualConsole()).forwardTo(console) :
  250. options.virtualConsole;
  251. if (!(transformed.windowOptions.virtualConsole instanceof VirtualConsole)) {
  252. throw new TypeError("virtualConsole must be an instance of VirtualConsole");
  253. }
  254. const { userAgent, effectiveDispatcher, loadSubresources } =
  255. extractResourcesOptions(options.resources, transformed.windowOptions.cookieJar);
  256. transformed.windowOptions.userAgent = userAgent;
  257. transformed.windowOptions.dispatcher = effectiveDispatcher;
  258. transformed.windowOptions.loadSubresources = loadSubresources;
  259. if (options.runScripts !== undefined) {
  260. transformed.windowOptions.runScripts = String(options.runScripts);
  261. if (transformed.windowOptions.runScripts === "dangerously") {
  262. transformed.windowOptions.parseOptions.scriptingEnabled = true;
  263. } else if (transformed.windowOptions.runScripts !== "outside-only") {
  264. throw new RangeError(`runScripts must be undefined, "dangerously", or "outside-only"`);
  265. }
  266. }
  267. if (options.beforeParse !== undefined) {
  268. transformed.beforeParse = options.beforeParse;
  269. }
  270. if (options.pretendToBeVisual !== undefined) {
  271. transformed.windowOptions.pretendToBeVisual = Boolean(options.pretendToBeVisual);
  272. }
  273. if (options.storageQuota !== undefined) {
  274. transformed.windowOptions.storageQuota = Number(options.storageQuota);
  275. }
  276. return transformed;
  277. }
  278. function normalizeHTML(html, mimeType) {
  279. let encoding = "UTF-8";
  280. if (html instanceof Uint8Array) {
  281. // leave as-is
  282. } else if (ArrayBuffer.isView(html)) {
  283. html = new Uint8Array(html.buffer, html.byteOffset, html.byteLength);
  284. } else if (html instanceof ArrayBuffer) {
  285. html = new Uint8Array(html);
  286. }
  287. if (html instanceof Uint8Array) {
  288. encoding = sniffHTMLEncoding(html, {
  289. xml: mimeType.isXML(),
  290. transportLayerEncodingLabel: mimeType.parameters.get("charset")
  291. });
  292. html = legacyHookDecode(html, encoding);
  293. } else {
  294. html = String(html);
  295. }
  296. return { html, encoding };
  297. }
  298. exports.JSDOM = JSDOM;
  299. exports.VirtualConsole = VirtualConsole;
  300. exports.CookieJar = CookieJar;
  301. exports.requestInterceptor = requestInterceptor;
  302. exports.toughCookie = toughCookie;