parent-process-storage.js (21305B)
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 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule( 9 "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", 10 { global: "contextual" } 11 ); 12 13 // ms of delay to throttle updates 14 const BATCH_DELAY = 200; 15 16 // Filters "stores-update" response to only include events for 17 // the storage type we desire 18 function getFilteredStorageEvents(updates, storageType) { 19 const filteredUpdate = Object.create(null); 20 21 // updateType will be "added", "changed", or "deleted" 22 for (const updateType in updates) { 23 if (updates[updateType][storageType]) { 24 if (!filteredUpdate[updateType]) { 25 filteredUpdate[updateType] = {}; 26 } 27 filteredUpdate[updateType][storageType] = 28 updates[updateType][storageType]; 29 } 30 } 31 32 return Object.keys(filteredUpdate).length ? filteredUpdate : null; 33 } 34 35 class ParentProcessStorage { 36 constructor(ActorConstructor, storageKey, storageType) { 37 this.ActorConstructor = ActorConstructor; 38 this.storageKey = storageKey; 39 this.storageType = storageType; 40 41 this.onStoresUpdate = this.onStoresUpdate.bind(this); 42 this.onStoresCleared = this.onStoresCleared.bind(this); 43 44 this.observe = this.observe.bind(this); 45 // Notifications that help us keep track of newly added windows and windows 46 // that got removed 47 Services.obs.addObserver(this, "window-global-created"); 48 Services.obs.addObserver(this, "window-global-destroyed"); 49 50 // bfcacheInParent is only enabled when fission is enabled 51 // and when Session History In Parent is enabled. (all three modes should now enabled all together) 52 loader.lazyGetter( 53 this, 54 "isBfcacheInParentEnabled", 55 () => 56 Services.appinfo.sessionHistoryInParent && 57 Services.prefs.getBoolPref("fission.bfcacheInParent", false) 58 ); 59 } 60 61 async watch(watcherActor, { onAvailable }) { 62 this.watcherActor = watcherActor; 63 this.onAvailable = onAvailable; 64 65 // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled, 66 // we're not getting a the window-global-created events. 67 // In such case, the watcher emits specific events that we can use instead. 68 this._offPageShow = watcherActor.on( 69 "bf-cache-navigation-pageshow", 70 ({ windowGlobal }) => this._onNewWindowGlobal(windowGlobal, true) 71 ); 72 73 if (watcherActor.sessionContext.type == "browser-element") { 74 const { browsingContext, innerWindowID: innerWindowId } = 75 watcherActor.browserElement; 76 await this._spawnActor(browsingContext.id, innerWindowId); 77 } else if (watcherActor.sessionContext.type == "webextension") { 78 // As the top level actor may change over time for the web extension, 79 // we don't have a good browsingContextID/innerWindowId to reference. 80 // Passing a `browsingContext` set to -1 will be interpreted by the frontend as a resource 81 // bound to the current top level target and will be automatically assigned to it. 82 await this._spawnActor(-1, null); 83 } else if (watcherActor.sessionContext.type == "all") { 84 // Note that there should be only one such target in the browser toolbox. 85 // The Parent Process Target Actor. 86 for (const targetActor of this.watcherActor.getTargetActorsInParentProcess()) { 87 const { browsingContextID, innerWindowId } = targetActor.form(); 88 await this._spawnActor(browsingContextID, innerWindowId); 89 } 90 } else { 91 throw new Error( 92 "Unsupported session context type=" + watcherActor.sessionContext.type 93 ); 94 } 95 } 96 97 onStoresUpdate(response) { 98 response = getFilteredStorageEvents(response, this.storageKey); 99 if (!response) { 100 return; 101 } 102 this.actor.emit("single-store-update", { 103 changed: response.changed, 104 added: response.added, 105 deleted: response.deleted, 106 }); 107 } 108 109 onStoresCleared(response) { 110 const cleared = response[this.storageKey]; 111 112 if (!cleared) { 113 return; 114 } 115 116 this.actor.emit("single-store-cleared", { 117 clearedHostsOrPaths: cleared, 118 }); 119 } 120 121 destroy() { 122 // Remove observers 123 Services.obs.removeObserver(this, "window-global-created"); 124 Services.obs.removeObserver(this, "window-global-destroyed"); 125 this._offPageShow(); 126 this._cleanActor(); 127 } 128 129 async _spawnActor(browsingContextID, innerWindowId) { 130 const storageActor = new StorageActorMock(this.watcherActor); 131 this.storageActor = storageActor; 132 this.actor = new this.ActorConstructor(storageActor); 133 134 // Some storage types require to prelist their stores 135 try { 136 await this.actor.populateStoresForHosts(); 137 } catch (e) { 138 // It can happen that the actor gets destroyed while populateStoresForHosts is being 139 // executed. 140 if (this.actor) { 141 throw e; 142 } 143 } 144 145 // If the actor was destroyed, we don't need to go further. 146 if (!this.actor) { 147 return; 148 } 149 150 // We have to manage the actor manually, because ResourceCommand doesn't 151 // use the protocol.js specification. 152 // resources-available-array is typed as "json" 153 // So that we have to manually handle stuff that would normally be 154 // automagically done by procotol.js 155 // 1) Manage the actor in order to have an actorID on it 156 this.watcherActor.manage(this.actor); 157 // 2) Convert to JSON "form" 158 const storage = this.actor.form(); 159 160 // All resources should have a resourceId and resourceKey 161 // attributes, so available/updated/destroyed callbacks work properly. 162 storage.resourceId = `${this.storageKey}-${innerWindowId}`; 163 storage.resourceKey = this.storageKey; 164 // NOTE: the resource command needs this attribute 165 storage.browsingContextID = browsingContextID; 166 167 this.onAvailable([storage]); 168 169 // Maps global events from `storageActor` shared for all storage-types, 170 // down to storage-type's specific actor `storage`. 171 storageActor.on("stores-update", this.onStoresUpdate); 172 173 // When a store gets cleared 174 storageActor.on("stores-cleared", this.onStoresCleared); 175 } 176 177 _cleanActor() { 178 this.actor?.destroy(); 179 this.actor = null; 180 if (this.storageActor) { 181 this.storageActor.off("stores-update", this.onStoresUpdate); 182 this.storageActor.off("stores-cleared", this.onStoresCleared); 183 this.storageActor.destroy(); 184 this.storageActor = null; 185 } 186 } 187 188 /** 189 * Event handler for any docshell update. This lets us figure out whenever 190 * any new window is added, or an existing window is removed. 191 */ 192 observe(subject, topic) { 193 if (topic === "window-global-created") { 194 this._onNewWindowGlobal(subject); 195 } 196 } 197 198 /** 199 * Handle WindowGlobal received via: 200 * - <window-global-created> (to cover regular navigations, with brand new documents) 201 * - <bf-cache-navigation-pageshow> (to cover history navications) 202 * 203 * @param {WindowGlobal} windowGlobal 204 * @param {boolean} isBfCacheNavigation 205 */ 206 async _onNewWindowGlobal(windowGlobal, isBfCacheNavigation) { 207 // We instantiate only one instance of parent process storage actors per toolbox 208 // when debugging addons as they don't really have any top level target 209 // which cause to switch to a brand new context and require to hook on that new context. 210 if (this.watcherActor.sessionContext.type == "webextension") { 211 return; 212 } 213 214 // Only process WindowGlobals which are related to the debugged scope. 215 if ( 216 !isWindowGlobalPartOfContext( 217 windowGlobal, 218 this.watcherActor.sessionContext, 219 { acceptNoWindowGlobal: true } 220 ) 221 ) { 222 return; 223 } 224 225 // Ignore about:blank 226 if (windowGlobal.documentURI.displaySpec === "about:blank") { 227 return; 228 } 229 230 // Only process top BrowsingContext (ignore same-process iframe ones) 231 const isTopContext = 232 windowGlobal.browsingContext.top == windowGlobal.browsingContext; 233 if (!isTopContext) { 234 return; 235 } 236 237 // We only want to spawn a new StorageActor if a new target is being created, i.e. 238 // - target switching is enabled and we're notified about a new top-level window global, 239 // via window-global-created 240 // - target switching is enabled OR bfCacheInParent is enabled, and a bfcache navigation 241 // is performed (See handling of "pageshow" event in DevToolsFrameChild) 242 const isNewTargetBeingCreated = 243 this.watcherActor.sessionContext.isServerTargetSwitchingEnabled || 244 (isBfCacheNavigation && this.isBfcacheInParentEnabled); 245 246 if (!isNewTargetBeingCreated) { 247 return; 248 } 249 250 // When server side target switching is enabled, we replace the StorageActor 251 // with a new one. 252 // On the frontend, the navigation will destroy the previous target, which 253 // will destroy the previous storage front, so we must notify about a new one. 254 255 // When we are target switching we keep the storage watcher, so we need 256 // to send a new resource to the client. 257 // However, we must ensure that we do this when the new target is 258 // already available, so we check innerWindowId to do it. 259 await new Promise(resolve => { 260 const listener = targetActorForm => { 261 if (targetActorForm.innerWindowId != windowGlobal.innerWindowId) { 262 return; 263 } 264 this.watcherActor.off("target-available-form", listener); 265 resolve(); 266 }; 267 this.watcherActor.on("target-available-form", listener); 268 }); 269 270 this._cleanActor(); 271 this._spawnActor( 272 windowGlobal.browsingContext.id, 273 windowGlobal.innerWindowId 274 ); 275 } 276 } 277 278 module.exports = ParentProcessStorage; 279 280 class StorageActorMock extends EventEmitter { 281 constructor(watcherActor) { 282 super(); 283 284 this.conn = watcherActor.conn; 285 this.watcherActor = watcherActor; 286 287 this.boundUpdate = {}; 288 289 // Notifications that help us keep track of newly added windows and windows 290 // that got removed 291 this.observe = this.observe.bind(this); 292 Services.obs.addObserver(this, "window-global-created"); 293 Services.obs.addObserver(this, "window-global-destroyed"); 294 295 // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled, 296 // we're not getting a the window-global-created/window-global-destroyed events. 297 // In such case, the watcher emits specific events that we can use as equivalent to 298 // window-global-created/window-global-destroyed. 299 // We only need to react to those events here if target switching is not enabled; when 300 // it is enabled, ParentProcessStorage will spawn a whole new actor which will allow 301 // the client to get the information it needs. 302 if (!this.watcherActor.sessionContext.isServerTargetSwitchingEnabled) { 303 this._offPageShow = watcherActor.on( 304 "bf-cache-navigation-pageshow", 305 ({ windowGlobal }) => { 306 // if a new target is created in the content process as a result of the bfcache 307 // navigation, we don't need to emit window-ready as a new StorageActorMock will 308 // be created by ParentProcessStorage. 309 // When server targets are disabled, this only happens when bfcache in parent is enabled. 310 if (this.isBfcacheInParentEnabled) { 311 return; 312 } 313 const windowMock = { location: windowGlobal.documentURI }; 314 this.emit("window-ready", windowMock); 315 } 316 ); 317 318 this._offPageHide = watcherActor.on( 319 "bf-cache-navigation-pagehide", 320 ({ windowGlobal }) => { 321 const windowMock = { location: windowGlobal.documentURI }; 322 // The listener of this events usually check that there are no other windows 323 // with the same host before notifying the client that it can remove it from 324 // the UI. The windows are retrieved from the `windows` getter, and in this case 325 // we still have a reference to the window we're navigating away from. 326 // We pass a `dontCheckHost` parameter alongside the window-destroyed event to 327 // always notify the client. 328 this.emit("window-destroyed", windowMock, { dontCheckHost: true }); 329 } 330 ); 331 } 332 } 333 334 destroy() { 335 // clear update throttle timeout 336 clearTimeout(this.batchTimer); 337 this.batchTimer = null; 338 // Remove observers 339 Services.obs.removeObserver(this, "window-global-created"); 340 Services.obs.removeObserver(this, "window-global-destroyed"); 341 if (this._offPageShow) { 342 this._offPageShow(); 343 } 344 if (this._offPageHide) { 345 this._offPageHide(); 346 } 347 } 348 349 get windows() { 350 return ( 351 this.watcherActor 352 .getAllBrowsingContexts() 353 .map(x => { 354 const uri = x.currentWindowGlobal.documentURI; 355 return { location: uri }; 356 }) 357 // NOTE: we are removing about:blank because we might get them for iframes 358 // whose src attribute has not been set yet. 359 .filter(x => x.location.displaySpec !== "about:blank") 360 ); 361 } 362 363 // NOTE: this uri argument is not a real window.Location, but the 364 // `currentWindowGlobal.documentURI` object passed from `windows` getter. 365 getHostName(uri) { 366 switch (uri.scheme) { 367 case "about": 368 case "file": 369 case "javascript": 370 case "resource": 371 return uri.displaySpec; 372 case "moz-extension": 373 case "http": 374 case "https": 375 return uri.prePath; 376 default: 377 // chrome: and data: do not support storage 378 return null; 379 } 380 } 381 382 getWindowFromHost(host) { 383 const hostBrowsingContext = this.watcherActor 384 .getAllBrowsingContexts() 385 .find(x => { 386 const hostName = this.getHostName(x.currentWindowGlobal.documentURI); 387 return hostName === host; 388 }); 389 // In case of WebExtension or BrowserToolbox, we may pass privileged hosts 390 // which don't relate to any particular window. 391 // Like "indexeddb+++fx-devtools" or "chrome". 392 // (callsites of this method are used to handle null returned values) 393 if (!hostBrowsingContext) { 394 return null; 395 } 396 397 const principal = 398 hostBrowsingContext.currentWindowGlobal.documentStoragePrincipal; 399 400 return { 401 document: { effectiveStoragePrincipal: principal }, 402 }; 403 } 404 405 /** 406 * Get the browsing contexts matching the given host. 407 * 408 * @param {string} host: The host for which we want the browsing contexts 409 * @returns Array<BrowsingContext> 410 */ 411 getBrowsingContextsFromHost(host) { 412 return this.watcherActor 413 .getAllBrowsingContexts() 414 .filter( 415 bc => this.getHostName(bc.currentWindowGlobal.documentURI) === host 416 ); 417 } 418 419 get parentActor() { 420 return { 421 isRootActor: this.watcherActor.sessionContext.type == "all", 422 addonId: this.watcherActor.sessionContext.addonId, 423 }; 424 } 425 426 /** 427 * Event handler for any docshell update. This lets us figure out whenever 428 * any new window is added, or an existing window is removed. 429 */ 430 async observe(windowGlobal, topic) { 431 // Only process WindowGlobals which are related to the debugged scope. 432 if ( 433 !isWindowGlobalPartOfContext( 434 windowGlobal, 435 this.watcherActor.sessionContext, 436 { acceptNoWindowGlobal: true } 437 ) 438 ) { 439 return; 440 } 441 442 // Ignore about:blank 443 if (windowGlobal.documentURI.displaySpec === "about:blank") { 444 return; 445 } 446 447 // Only notify about remote iframe windows when JSWindowActor based targets are enabled 448 // We will create a new StorageActor for the top level tab documents when server side target 449 // switching is enabled 450 const isTopContext = 451 windowGlobal.browsingContext.top == windowGlobal.browsingContext; 452 if ( 453 isTopContext && 454 this.watcherActor.sessionContext.isServerTargetSwitchingEnabled 455 ) { 456 return; 457 } 458 459 // emit window-wready and window-destroyed events when needed 460 const windowMock = { location: windowGlobal.documentURI }; 461 if (topic === "window-global-created") { 462 this.emit("window-ready", windowMock); 463 } else if (topic === "window-global-destroyed") { 464 this.emit("window-destroyed", windowMock); 465 } 466 } 467 468 /** 469 * This method is called by the registered storage types so as to tell the 470 * Storage Actor that there are some changes in the stores. Storage Actor then 471 * notifies the client front about these changes at regular (BATCH_DELAY) 472 * interval. 473 * 474 * @param {string} action 475 * The type of change. One of "added", "changed" or "deleted" 476 * @param {string} storeType 477 * The storage actor in which this change has occurred. 478 * @param {object} data 479 * The update object. This object is of the following format: 480 * - { 481 * <host1>: [<store_names1>, <store_name2>...], 482 * <host2>: [<store_names34>...], 483 * } 484 * Where host1, host2 are the host in which this change happened and 485 * [<store_namesX] is an array of the names of the changed store objects. 486 * Pass an empty array if the host itself was affected: either completely 487 * removed or cleared. 488 */ 489 // eslint-disable-next-line complexity 490 update(action, storeType, data) { 491 if (action == "cleared") { 492 this.emit("stores-cleared", { [storeType]: data }); 493 return null; 494 } 495 496 if (this.batchTimer) { 497 clearTimeout(this.batchTimer); 498 } 499 if (!this.boundUpdate[action]) { 500 this.boundUpdate[action] = {}; 501 } 502 if (!this.boundUpdate[action][storeType]) { 503 this.boundUpdate[action][storeType] = {}; 504 } 505 for (const host in data) { 506 if (!this.boundUpdate[action][storeType][host]) { 507 this.boundUpdate[action][storeType][host] = []; 508 } 509 for (const name of data[host]) { 510 if (!this.boundUpdate[action][storeType][host].includes(name)) { 511 this.boundUpdate[action][storeType][host].push(name); 512 } 513 } 514 } 515 if (action == "added") { 516 // If the same store name was previously deleted or changed, but now is 517 // added somehow, dont send the deleted or changed update. 518 this.removeNamesFromUpdateList("deleted", storeType, data); 519 this.removeNamesFromUpdateList("changed", storeType, data); 520 } else if ( 521 action == "changed" && 522 this.boundUpdate.added && 523 this.boundUpdate.added[storeType] 524 ) { 525 // If something got added and changed at the same time, then remove those 526 // items from changed instead. 527 this.removeNamesFromUpdateList( 528 "changed", 529 storeType, 530 this.boundUpdate.added[storeType] 531 ); 532 } else if (action == "deleted") { 533 // If any item got delete, or a host got delete, no point in sending 534 // added or changed update 535 this.removeNamesFromUpdateList("added", storeType, data); 536 this.removeNamesFromUpdateList("changed", storeType, data); 537 538 for (const host in data) { 539 if ( 540 !data[host].length && 541 this.boundUpdate.added && 542 this.boundUpdate.added[storeType] && 543 this.boundUpdate.added[storeType][host] 544 ) { 545 delete this.boundUpdate.added[storeType][host]; 546 } 547 if ( 548 !data[host].length && 549 this.boundUpdate.changed && 550 this.boundUpdate.changed[storeType] && 551 this.boundUpdate.changed[storeType][host] 552 ) { 553 delete this.boundUpdate.changed[storeType][host]; 554 } 555 } 556 } 557 558 this.batchTimer = setTimeout(() => { 559 clearTimeout(this.batchTimer); 560 this.emit("stores-update", this.boundUpdate); 561 this.boundUpdate = {}; 562 }, BATCH_DELAY); 563 564 return null; 565 } 566 567 /** 568 * This method removes data from the this.boundUpdate object in the same 569 * manner like this.update() adds data to it. 570 * 571 * @param {string} action 572 * The type of change. One of "added", "changed" or "deleted" 573 * @param {string} storeType 574 * The storage actor for which you want to remove the updates data. 575 * @param {object} data 576 * The update object. This object is of the following format: 577 * - { 578 * <host1>: [<store_names1>, <store_name2>...], 579 * <host2>: [<store_names34>...], 580 * } 581 * Where host1, host2 are the hosts which you want to remove and 582 * [<store_namesX] is an array of the names of the store objects. 583 */ 584 removeNamesFromUpdateList(action, storeType, data) { 585 for (const host in data) { 586 if ( 587 this.boundUpdate[action] && 588 this.boundUpdate[action][storeType] && 589 this.boundUpdate[action][storeType][host] 590 ) { 591 for (const name of data[host]) { 592 const index = this.boundUpdate[action][storeType][host].indexOf(name); 593 if (index > -1) { 594 this.boundUpdate[action][storeType][host].splice(index, 1); 595 } 596 } 597 if (!this.boundUpdate[action][storeType][host].length) { 598 delete this.boundUpdate[action][storeType][host]; 599 } 600 } 601 } 602 return null; 603 } 604 }