Home Reference Source

lib/frames/FrameManager.js

/* eslint-env node, browser */
const util = require('util')
const EventEmitter = require('eventemitter3')
const { assert, helper } = require('../helper')
const Events = require('../Events')
const {
  ExecutionContext,
  EVALUATION_SCRIPT_URL
} = require('../executionContext')
const LifecycleWatcher = require('../LifecycleWatcher')
const Frame = require('./Frame')
const FrameResourceTree = require('./FrameResourceTree')

const UTILITY_WORLD_NAME = '__chrome-remote-interface-extra_utility_world__'

class FrameManager extends EventEmitter {
  /**
   * @param {Chrome|CRIConnection|CDPSession|Object} client
   * @param {!Object} frameTree
   * @param {!TimeoutSettings} timeoutSettings
   * @param {NetworkManager} [networkManager]
   * @param {Page} [page]
   */
  constructor (client, frameTree, timeoutSettings, networkManager, page) {
    super()
    /**
     * @type {!Chrome|CRIConnection|CDPSession|Object}
     */
    this._client = client

    /**
     * @type {!TimeoutSettings}
     */
    this._timeoutSettings = timeoutSettings

    /**
     * @type {?NetworkManager}
     */
    this._networkManager = networkManager

    /**
     * @type {?Page}
     */
    this._page = page

    /**
     * @type {Frame}
     */
    this._mainFrame = null

    /** @type {Map<string, Frame>} */
    this._frames = new Map()
    /** @type {Map<number, ExecutionContext>} */
    this._contextIdToContext = new Map()
    /** @type {Set<string>} */
    this._isolatedWorlds = new Set()

    this._client.on('Page.frameAttached', event => this._onFrameAttached(event))
    this._client.on('Page.frameNavigated', event =>
      this._onFrameNavigated(event)
    )
    this._client.on('Page.navigatedWithinDocument', event =>
      this._onFrameNavigatedWithinDocument(event)
    )
    this._client.on('Page.frameDetached', event => this._onFrameDetached(event))
    this._client.on('Page.frameStoppedLoading', event =>
      this._onFrameStoppedLoading(event)
    )
    this._client.on('Runtime.executionContextCreated', event =>
      this._onExecutionContextCreated(event)
    )
    this._client.on('Runtime.executionContextDestroyed', event =>
      this._onExecutionContextDestroyed(event)
    )
    this._client.on('Runtime.executionContextsCleared', () =>
      this._onExecutionContextsCleared()
    )
    this._client.on('Page.lifecycleEvent', event =>
      this._onLifecycleEvent(event)
    )
    this._handleFrameTree(frameTree, true)
  }

  /**
   * @return {?Page}
   */
  page () {
    return this._page
  }

  /**
   * @return {!Frame}
   */
  mainFrame () {
    return this._mainFrame
  }

  /**
   * @return {!Array<Frame>}
   */
  frames () {
    return Array.from(this._frames.values())
  }

  /**
   * @param {!string} frameId
   * @return {?Frame}
   */
  frame (frameId) {
    return this._frames.get(frameId) || null
  }

  /**
   * @param {!Frame} frame
   * @param {string} url
   * @param {!{referer?: string, timeout?: number, waitUntil?: string|Array<string>, transitionType?: string}=} options
   * @return {Promise<Response|undefined>}
   */
  async navigateFrame (frame, url, options = {}) {
    assertNoLegacyNavigationOptions(options)
    const {
      waitUntil = ['load'],
      timeout = this._timeoutSettings.navigationTimeout(),
      transitionType
    } = options
    let referer = options.referer
    if (referer == null) {
      if (this._networkManager != null) {
        referer = this._networkManager.extraHTTPHeaders()['referer']
      }
    }
    const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout)
    const endnObj = { ensureNewDocumentNavigation: false }
    const navigationParams = {
      url,
      transitionType,
      referrer: referer,
      frameId: frame._id
    }
    let error = await Promise.race([
      this._navigate(navigationParams, endnObj),
      watcher.timeoutOrTerminationPromise()
    ])
    if (!error) {
      error = await Promise.race([
        watcher.timeoutOrTerminationPromise(),
        endnObj.ensureNewDocumentNavigation
          ? watcher.newDocumentNavigationPromise()
          : watcher.sameDocumentNavigationPromise()
      ])
    }
    watcher.dispose()
    if (error) throw error
    return watcher.navigationResponse()
  }

  /**
   * @param {!Frame} frame
   * @param {!{timeout?: number, waitUntil?: string|Array<string>}=} options
   * @return {Promise<Response|undefined>}
   */
  async waitForFrameNavigation (frame, options = {}) {
    assertNoLegacyNavigationOptions(options)
    const {
      waitUntil = ['load'],
      timeout = this._timeoutSettings.navigationTimeout()
    } = options
    const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout)
    const error = await Promise.race([
      watcher.timeoutOrTerminationPromise(),
      watcher.sameDocumentNavigationPromise(),
      watcher.newDocumentNavigationPromise()
    ])
    watcher.dispose()
    if (error) throw error
    return watcher.navigationResponse()
  }

  async ensureSecondaryDOMWorld () {
    await this._ensureIsolatedWorld(UTILITY_WORLD_NAME)
  }

  /**
   * @return {Promise<FrameResourceTree>}
   * @since chrome-remote-interface-extra
   */
  async getResourceTree () {
    const { frameTree } = await this._client.send('Page.getResourceTree')
    return new FrameResourceTree(this, frameTree)
  }

  /**
   * Returns content of the given resource. EXPERIMENTAL
   * @param {string} frameId - Frame id to get resource for
   * @param {string} url - URL of the resource to get content for
   * @return {Promise<Buffer>} - Resource content
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Page#method-getResourceContent
   * @since chrome-remote-interface-extra
   */
  async getFrameResourceContent (frameId, url) {
    const { content, base64Encoded } = await this._client.send(
      'Page.getResourceContent',
      { frameId, url }
    )
    return Buffer.from(content, base64Encoded ? 'base64' : 'utf8')
  }

  /**
   * @param {string} name
   */
  async _ensureIsolatedWorld (name) {
    if (this._isolatedWorlds.has(name)) return
    this._isolatedWorlds.add(name)
    await this._client.send('Page.addScriptToEvaluateOnNewDocument', {
      source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`,
      worldName: name
    })
    await Promise.all(
      this.frames().map(frame =>
        this._client
          .send('Page.createIsolatedWorld', {
            frameId: frame._id,
            grantUniveralAccess: true,
            worldName: name
          })
          .catch(helper.noop)
      )
    )
  }

  /**
   * @param {{transitionType: ?string, frameId: string, url: string, referrer: ?string}} navigationParams
   * @param {{ensureNewDocumentNavigation: boolean}} endnObj
   * @return {Promise<Error|undefined>}
   * @since chrome-remote-interface-extra
   */
  async _navigate (navigationParams, endnObj) {
    try {
      const response = await this._client.send(
        'Page.navigate',
        navigationParams
      )
      endnObj.ensureNewDocumentNavigation = !!response.loaderId
      return response.errorText
        ? new Error(`${response.errorText} at ${navigationParams.url}`)
        : null
    } catch (error) {
      return error
    }
  }

  /**
   * @param {number} contextId
   * @return {!ExecutionContext}
   * @since chrome-remote-interface-extra
   */
  executionContextById (contextId) {
    const context = this._contextIdToContext.get(contextId)
    assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId)
    return context
  }

  /**
   * @param {!Object} event
   */
  _onLifecycleEvent (event) {
    const frame = this._frames.get(event.frameId)
    if (!frame) return
    frame._onLifecycleEvent(event.loaderId, event.name)
    this.emit(Events.FrameManager.LifecycleEvent, frame)
  }

  /**
   * @param {!Object} event
   */
  _onFrameAttached (event) {
    if (this._frames.has(event.frameId)) return
    assert(
      event.parentFrameId,
      'A frame attached but does not have a parent id'
    )
    const parentFrame = this._frames.get(event.parentFrameId)
    const frame = new Frame(this, this._client, parentFrame, event.frameId)
    this._frames.set(frame.id(), frame)
    this.emit(Events.FrameManager.FrameAttached, frame)
  }

  /**
   * @param {!Object} event
   */
  _onFrameNavigated (event) {
    const framePayload = event.frame
    const isMainFrame = !framePayload.parentId
    let frame = isMainFrame
      ? this._mainFrame
      : this._frames.get(framePayload.id)
    assert(
      isMainFrame || frame,
      'We either navigate top level or have old version of the navigated frame'
    )

    // Detach all child frames first.
    if (frame) {
      const childFrames = frame.childFrames()
      for (let i = 0; i < childFrames.length; i++) {
        this._removeFramesRecursively(childFrames[i])
      }
    }

    // Update or create main frame.
    if (isMainFrame) {
      if (frame) {
        // Update frame id to retain frame identity on cross-process navigation.
        this._frames.delete(frame._id)
        frame._id = framePayload.id
      } else {
        // Initial main frame navigation.
        frame = new Frame(this, this._client, null, framePayload.id)
      }
      this._frames.set(framePayload.id, frame)
      this._mainFrame = frame
    }

    // Update frame payload.
    frame._navigated(framePayload)

    this.emit(Events.FrameManager.FrameNavigated, frame)
  }

  /**
   * @param {!Object} event
   */
  _onFrameNavigatedWithinDocument (event) {
    const frame = this._frames.get(event.frameId)
    if (!frame) {
      console.log(
        'A frame navigated within the document but we do not have that frame',
        event
      )
      return
    }
    frame._navigatedWithinDocument(event.url)
    this.emit(Events.FrameManager.FrameNavigatedWithinDocument, frame)
    this.emit(Events.FrameManager.FrameNavigated, frame)
  }

  /**
   * @param {!Object} event
   */
  _onFrameDetached (event) {
    const frame = this._frames.get(event.frameId)
    if (frame) {
      this._removeFramesRecursively(frame)
    } else {
      console.log(
        'A frame detached from the document but we do not have that frame',
        event
      )
    }
  }

  /**
   * @param {!Object} event
   */
  _onFrameStoppedLoading (event) {
    const frame = this._frames.get(event.frameId)
    if (!frame) {
      console.log(
        'A frame stopped loading but we do not have that frame',
        event
      )
      return
    }
    frame._onLoadingStopped()
    this.emit(Events.FrameManager.LifecycleEvent, frame)
  }

  /**
   *
   * @param {!Object} event
   */
  _onExecutionContextCreated (event) {
    const contextPayload = event.context
    const frameId = contextPayload.auxData
      ? contextPayload.auxData.frameId
      : null
    const frame = this._frames.get(frameId) || null
    let world = null
    if (frame) {
      if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) {
        world = frame._mainWorld
      } else if (contextPayload.name === UTILITY_WORLD_NAME) {
        world = frame._secondaryWorld
      }
    }
    if (
      contextPayload.auxData &&
      contextPayload.auxData['type'] === 'isolated'
    ) {
      this._isolatedWorlds.add(contextPayload.name)
    }
    /** @type {!ExecutionContext} */
    const context = new ExecutionContext(this._client, contextPayload, world)
    if (world) world._setContext(context)
    this._contextIdToContext.set(contextPayload.id, context)
  }

  /**
   * @param {!Object} event
   */
  _onExecutionContextDestroyed (event) {
    const context = this._contextIdToContext.get(event.executionContextId)
    if (!context) return
    this._contextIdToContext.delete(event.executionContextId)
    if (context._world) context._world._setContext(null)
  }

  _onExecutionContextsCleared () {
    for (const context of this._contextIdToContext.values()) {
      if (context._world) context._world._setContext(null)
    }
    this._contextIdToContext.clear()
  }

  /**
   * @param {!Frame} frame
   */
  _removeFramesRecursively (frame) {
    const childFrames = frame.childFrames()
    for (let i = 0; i < childFrames.length; i++) {
      this._removeFramesRecursively(childFrames[i])
    }
    frame._detach()
    this._frames.delete(frame._id)
    this.emit(Events.FrameManager.FrameDetached, frame)
  }

  /**
   * @param {!Object} frameTree
   * @param {boolean} [first = false]
   */
  _handleFrameTree (frameTree, first = false) {
    const parentFrame = this._frames.get(frameTree.frame.parentId || '')
    const frame = Frame.fromCDPFrame(this, frameTree.frame, parentFrame)
    this._frames.set(frame.id(), frame)
    if (parentFrame == null && first) {
      this._mainFrame = frame
    } else if (first) {
      console.log(
        'handled first frame in frame tree and it has a parent frame???',
        frame
      )
    }
    if (!frameTree.childFrames) return
    const childFrames = frameTree.childFrames
    for (let i = 0; i < childFrames.length; i++) {
      this._handleFrameTree(childFrames[i])
    }
  }

  toJSON () {
    return { mainFrame: this._mainFrame, frames: this.frames() }
  }

  /** @ignore */
  // eslint-disable-next-line space-before-function-paren
  [util.inspect.custom](depth, options) {
    if (depth < 0) {
      return options.stylize('[FrameManager]', 'special')
    }

    const newOptions = Object.assign({}, options, {
      depth: options.depth == null ? null : options.depth - 1
    })
    const inner = util.inspect(
      { mainFrame: this._mainFrame, frames: this.frames() },
      newOptions
    )
    return `${options.stylize('FrameManager', 'special')} ${inner}`
  }
}

function assertNoLegacyNavigationOptions (options) {
  assert(
    options['networkIdleTimeout'] === undefined,
    'ERROR: networkIdleTimeout option is no longer supported.'
  )
  assert(
    options['networkIdleInflight'] === undefined,
    'ERROR: networkIdleInflight option is no longer supported.'
  )
}

/**
 * @type {FrameManager}
 */
module.exports = FrameManager