HTMLSourcesCache.sys.mjs (6430B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * Helper class spawn independently from target to watcher for HTML source content 7 * being emitted by the C++ HTML Parser. 8 * 9 * As HTML sources are parsed before the HTML document's WindowGlobal is created, 10 * they will be parsed before the target gets created. 11 * This class is instantiated as soon as we start watching for WindowGlobal's 12 * and will hold HTML sources and deliver them on-demand to SourcesManager. 13 * 14 * This cache clears itself automatically when the WindowGlobals are destroyed. 15 */ 16 class HtmlCache { 17 // Map<string -> 18 // Map<string -> Object{ 19 // content: string 20 // complete: boolean, 21 // parserID: string, 22 // }> 23 // > 24 // Nested Maps of source objects keyed by source url, stored into 25 // this map keyed by Browsing Context ID. 26 #sourcesByBrowsingContext = new Map(); 27 28 // Map<string: number> 29 // Number of active listeners (WindowGlobal target watchers) for all currently observed browser element IDs. 30 #noOfActiveTargetWatchers = new Map(); 31 32 // Map<number -> number> 33 // Map the browsing context ID for each currently active Window global InnerWindowID 34 // 35 // We have to keep track of the relationship between innerWindowID and browsingContextID 36 // as inner-window-destroyed only emits the innerWindowID number, from which 37 // we can't derivate the browsingContextID required to clear `#sourcesByBrowsingContext` Map. 38 #browsingContextIDByInnerWindowId = new Map(); 39 40 constructor() { 41 Services.obs.addObserver(this, "devtools-html-content"); 42 Services.obs.addObserver(this, "document-element-inserted"); 43 Services.obs.addObserver(this, "inner-window-destroyed"); 44 } 45 46 destroy() { 47 Services.obs.removeObserver(this, "devtools-html-content"); 48 Services.obs.removeObserver(this, "document-element-inserted"); 49 Services.obs.removeObserver(this, "inner-window-destroyed"); 50 } 51 52 watch(browserId) { 53 const noOfTargetWatchers = 54 this.#noOfActiveTargetWatchers.get(browserId) || 0; 55 this.#noOfActiveTargetWatchers.set(browserId, noOfTargetWatchers + 1); 56 } 57 58 unwatch(browserId) { 59 const noOfTargetWatchers = this.#noOfActiveTargetWatchers.get(browserId); 60 if (noOfTargetWatchers > 1) { 61 this.#noOfActiveTargetWatchers.set(browserId, noOfTargetWatchers - 1); 62 } else { 63 this.#noOfActiveTargetWatchers.delete(browserId); 64 } 65 } 66 67 get(browsingContextID, url, partial) { 68 const sourcesByUrl = this.#sourcesByBrowsingContext.get(browsingContextID); 69 if (sourcesByUrl) { 70 const source = sourcesByUrl.get(url); 71 if (source) { 72 if (!partial && !source.complete) { 73 return source.onComplete.then(() => { 74 return { 75 content: source.content, 76 contentType: "text/html", 77 }; 78 }); 79 } 80 return { 81 content: source.content, 82 contentType: "text/html", 83 }; 84 } 85 } 86 if (partial) { 87 return { 88 content: "", 89 contentType: "", 90 }; 91 } 92 return null; 93 } 94 95 /** 96 * Listener for new HTML content. 97 */ 98 observe(subject, topic, data) { 99 if (topic === "inner-window-destroyed") { 100 const innerWindowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; 101 const browsingContextID = 102 this.#browsingContextIDByInnerWindowId.get(innerWindowId); 103 if (browsingContextID) { 104 this.#sourcesByBrowsingContext.delete(browsingContextID); 105 this.#browsingContextIDByInnerWindowId.delete(innerWindowId); 106 } 107 } else if (topic === "document-element-inserted") { 108 const window = subject.defaultView; 109 // Ignore any non-HTML document 110 if (!window) { 111 return; 112 } 113 const innerWindowId = window.windowGlobalChild.innerWindowId; 114 const browsingContextID = window.browsingContext.id; 115 this.#browsingContextIDByInnerWindowId.set( 116 innerWindowId, 117 browsingContextID 118 ); 119 } else if (topic === "devtools-html-content") { 120 const { 121 parserID, 122 browserId, 123 browsingContextID, 124 uri, 125 contents, 126 complete, 127 } = JSON.parse(data); 128 129 // Only save data if the related browser element is being observed 130 if (!this.#noOfActiveTargetWatchers.has(browserId)) { 131 return; 132 } 133 134 let sourcesByUrl = this.#sourcesByBrowsingContext.get(browsingContextID); 135 if (!sourcesByUrl) { 136 sourcesByUrl = new Map(); 137 this.#sourcesByBrowsingContext.set(browsingContextID, sourcesByUrl); 138 } 139 const source = sourcesByUrl.get(uri); 140 if (source) { 141 // We received many devtools-html-content events, if we already received one, 142 // aggregate the data with the one we already received. 143 if (source.parserID == parserID) { 144 source.content += contents; 145 source.complete = complete; 146 147 // After the HTML has finished loading, resolve any promises 148 // waiting for the complete file contents. Waits will only 149 // occur when the URL was ever partially loaded. 150 if (complete) { 151 source.resolveComplete(); 152 } 153 } 154 } else if (contents) { 155 const { promise, resolve } = Promise.withResolvers(); 156 // Ensure that `contents` is non-empty. We may miss all the devtools-html-content events except the last 157 // one which has an empty `contents` and `complete` set to true. 158 // This reproduces when opening a same-process iframe. In this particular scenario, we instantiate the target and thread actor 159 // on `DOMDocElementInserted` and the HTML document is already parsed, but we still receive this one very last notification. 160 sourcesByUrl.set(uri, { 161 // String: HTML source text content 162 content: contents, 163 // Boolean: is the source complete 164 complete, 165 // String: uuid generated by the html parser to uniquely identify a document 166 parserID, 167 // Promise: resolved when the source is complete 168 onComplete: promise, 169 // Function: to be called when the source is complete 170 resolveComplete: resolve, 171 }); 172 } 173 } 174 } 175 } 176 177 export const HTMLSourcesCache = new HtmlCache();