Home Reference Source

lib/workers/WorkerManager.js

const util = require('util')
const EventEmitter = require('eventemitter3')
const { CRIConnection } = require('../connection')
const ConsoleMessage = require('../ConsoleMessage')
const Events = require('../Events')
const { assert, helper, debugError } = require('../helper')
const ServiceWorker = require('./ServiceWorker')
const Worker = require('./Worker')

/**
 * Combination manger that juggles both Web Workers and ServiceWorkers
 * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker
 * @since chrome-remote-interface-extra
 */
class WorkerManager extends EventEmitter {
  /**
   * @param {Chrome|CRIConnection|CDPSession|Object} client
   */
  constructor (client) {
    super()
    /** @type {Chrome|CRIConnection|CDPSession|Object} */
    this._client = client

    /** @type {Map<string, Worker>} */
    this._workers = new Map()

    /**
     * @type {Map<string, ServiceWorker>}
     */
    this._serviceWorkers = new Map()

    /**
     * @type {Map<string, string>}
     * @private
     */
    this._serviceWorkerTargets = new Map()

    /**
     * @type {boolean}
     * @private
     */
    this._serviceWorkersEnabled = false

    /**
     * @type {boolean}
     * @private
     */
    this._workersEnabled = false

    /**
     * @type {boolean}
     * @private
     */
    this._autoAttachEnabled = false

    this._client.on(
      'ServiceWorker.workerErrorReported',
      this._onWorkerErrorReported.bind(this)
    )

    this._client.on(
      'ServiceWorker.workerRegistrationUpdated',
      this._onWorkerRegistrationUpdated.bind(this)
    )

    this._client.on(
      'ServiceWorker.workerVersionUpdated',
      this._onWorkerVersionUpdated.bind(this)
    )

    this._client.on(
      'Target.attachedToTarget',
      this._onAttachedToTarget.bind(this)
    )

    this._client.on(
      'Target.detachedFromTarget',
      this._onDetachedFromTarget.bind(this)
    )

    this._addWorkerConsoleMessage = this._addWorkerConsoleMessage.bind(this)
    this._handleException = this._handleException.bind(this)
  }

  /**
   * Returns all ServiceWorkers the manager knows about
   * @return {Array<ServiceWorker>}
   */
  serviceWorkers () {
    return Array.from(this._serviceWorkers.values())
  }

  /**
   * Retrieve the ServiceWorker associated with the supplied registration id
   * @param {string} registrationId - The registration id of the desired ServiceWorker
   * @return {ServiceWorker}
   */
  serviceWorker (registrationId) {
    return this._serviceWorkers.get(registrationId)
  }

  /**
   * @return {Array<Worker>}
   */
  workers () {
    return Array.from(this._workers.values())
  }

  /**
   * @return {boolean}
   */
  serviceWorkerDomainEnabled () {
    return this._serviceWorkersEnabled
  }

  /**
   * @return {boolean}
   */
  workerMonitoringEnabled () {
    return this._workersEnabled
  }

  /**
   * Enables receiving service worker events
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-enable
   */
  async enableServiceWorkerDomain () {
    if (this._serviceWorkersEnabled) return
    this._serviceWorkersEnabled = true
    await this._enableAutoAttach()
    await this._client.send('ServiceWorker.enable')
  }

  /**
   * Disables receiving service worker events
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-disable
   */
  async disableServiceWorkerDomain () {
    if (!this._serviceWorkersEnabled) return
    this._serviceWorkersEnabled = false
    await this._disableAutoAttach()
    await this._client.send('ServiceWorker.disable')
  }

  /**
   * Enables the monitoring of Worker creation and removal
   * @return {Promise<void>}
   */
  async enableWorkerMonitoring () {
    if (this._workersEnabled) return
    this._workersEnabled = true
    await this._enableAutoAttach()
  }

  /**
   * Disables the monitoring of Worker creation and removal
   * @return {Promise<void>}
   */
  async disableWorkerMonitoring () {
    if (!this._workersEnabled) return
    this._workersEnabled = false
    await this._disableAutoAttach()
  }

  /**
   * @param {boolean} forceUpdateOnPageLoad
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-setForceUpdateOnPageLoad
   */
  async swSetForceUpdateOnPageLoad (forceUpdateOnPageLoad) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isBoolean(forceUpdateOnPageLoad),
      `The forceUpdateOnPageLoad param must be of type "string", received ${typeof forceUpdateOnPageLoad}`
    )
    this._forceUpdateState = forceUpdateOnPageLoad
    await this._client.send('ServiceWorker.setForceUpdateOnPageLoad', {
      forceUpdateOnPageLoad
    })
  }
  /**
   * Delivers a push message to the supplied origin from the ServiceWorker who's registrationId is the one supplied
   * @param {{origin: string, registrationId: string, data: string}} pushMessage
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-deliverPushMessage
   */
  async swDeliverPushMessage ({ origin, registrationId, data }) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(origin),
      `The origin must be of type "string", received ${typeof origin}`
    )
    assert(
      helper.isString(registrationId),
      `The registrationId must be of type "string", received ${typeof registrationId}`
    )
    assert(
      helper.isString(data),
      `The data sent must be of type "string", received ${typeof data}`
    )
    await this._client.send('ServiceWorker.deliverPushMessage', {
      origin,
      registrationId,
      data
    })
  }
  /**
   * Delivers a sync event to the supplied origin from the ServiceWorker who's registrationId is the one supplied
   * @param {{origin: string, registrationId: string, tag: string, lastChance: boolean}} syncEvent
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-dispatchSyncEvent
   */
  async swDispatchSyncEvent ({ origin, registrationId, tag, lastChance }) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(origin),
      `The origin must be of type "string", received ${typeof origin}`
    )
    assert(
      helper.isString(registrationId),
      `The registrationId must be of type "string", received ${typeof registrationId}`
    )
    assert(
      helper.isString(tag),
      `The data sent must be of type "string", received ${typeof data}`
    )
    await this._client.send('ServiceWorker.dispatchSyncEvent', {
      origin,
      registrationId,
      tag,
      lastChance: lastChance || false
    })
  }
  /**
   * Makes the ServiceWorker(s) under the supplied scope URL skip waiting
   * @param {string} scopeURL - The scope URL for the ServiceWorker(s)
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-skipWaiting
   */
  async makeServiceWorkerSkipWaiting (scopeURL) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(scopeURL),
      `The scopeURL param must be of type "string", received ${typeof scopeURL}`
    )
    await this._client.send('ServiceWorker.skipWaiting', { scopeURL })
  }

  /**
   * Starts the ServiceWorker(s) under the supplied scope URL
   * @param {string} scopeURL - The scope URL for the ServiceWorker(s) to be started
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-startWorker
   */
  async startServiceWorker (scopeURL) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(scopeURL),
      `The scopeURL param must be of type "string", received ${typeof scopeURL}`
    )
    await this._client.send('ServiceWorker.startWorker', { scopeURL })
  }

  /**
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-stopAllWorkers
   */
  async stopAllServiceWorker () {
    await this._client.send('ServiceWorker.stopAllWorkers')
  }

  /**
   * @param {string} versionId
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-stopWorker
   */
  async stopServiceWorker (versionId) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(versionId),
      `The scopeURL param must be of type "string", received ${typeof versionId}`
    )
    await this._client.send('ServiceWorker.stopWorker', { versionId })
  }
  /**
   * Un-registers the ServiceWorker(s) registered under the supplied scope URL
   * @param {string} scopeURL - The scope URL to unregister the ServiceWorker(s) for
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-unregister
   */
  async unregisterServiceWorker (scopeURL) {
    if (!this._serviceWorkersEnabled) {
      throw new Error('Must enable service worker domain')
    }
    assert(
      helper.isString(scopeURL),
      `The scopeURL param must be of type "string", received ${typeof scopeURL}`
    )
    await this._client.send('ServiceWorker.unregister', { scopeURL })
  }
  /**
   * Updates the ServiceWorker(s) registrations under the supplied scope URL
   * @param {string} scopeURL - The scope URL for the ServiceWorker(s) to update their registrations
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker#method-updateRegistration
   */
  async updateServiceWorkerRegistration (scopeURL) {
    assert(
      helper.isString(scopeURL),
      `The scopeURL param must be of type "string", received ${typeof scopeURL}`
    )
    await this._client.send('ServiceWorker.updateRegistration', { scopeURL })
  }

  /**
   * Clears the known service workers.
   * {@link Page} uses this method to get rid of them when navigating
   */
  _clearKnownWorkers () {
    for (const sw of this._serviceWorkers.values()) {
      sw._destroyed()
    }
    this._serviceWorkers.clear()
  }

  _removeServiceWorker (registrationId) {
    const sw = this._serviceWorkers.get(registrationId)
    if (sw) {
      this.emit(Events.WorkerManager.ServiceWorkerDeleted, sw)
    }
    this._serviceWorkers.delete(registrationId)
  }

  /**
   *
   * @param {ServiceWorker} swappedSW
   * @param {string} prevRegId
   */
  _ensureServiceWorkerSwapped (swappedSW, prevRegId) {
    const maybeSwappedOut = this._serviceWorkers.get(prevRegId)
    if (maybeSwappedOut && maybeSwappedOut !== swappedSW) {
      if (!this._serviceWorkers.has(swappedSW.registrationId())) {
        this._serviceWorkers.set(swappedSW.registrationId(), swappedSW)
      }
      this._removeServiceWorker(prevRegId)
    }
  }

  /**
   * @param {CDPServiceWorkerErrorMessage} errorMessage
   * @private
   */
  _onWorkerErrorReported ({ errorMessage }) {
    const sw = this._serviceWorkers.get(errorMessage.registrationId)
    if (sw) {
      sw._errorReported(errorMessage)
    }
  }

  /**
   * @param {{registrations: Array<CDPServiceWorkerRegistration>}} event
   * @private
   */
  _onWorkerRegistrationUpdated ({ registrations }) {
    for (let i = 0; i < registrations.length; i++) {
      this._updateSW(registrations[i])
    }
  }

  /**
   * @param {{versions: Array<CDPServiceWorkerVersion>}} event
   * @private
   */
  _onWorkerVersionUpdated ({ versions }) {
    for (let i = 0; i < versions.length; i++) {
      this._updateSW(versions[i], true)
    }
  }

  /**
   *
   * @param {CDPServiceWorkerRegistration|CDPServiceWorkerVersion} updateHow
   * @param {boolean} [isVersion = false]
   * @private
   */
  _updateSW (updateHow, isVersion = false) {
    // version or registration updates both have the registrationId property
    const regId = updateHow.registrationId
    let serviceWorker = this._serviceWorkers.get(regId)
    const isAdd = serviceWorker == null
    if (isAdd) {
      serviceWorker = new ServiceWorker(this)
      this._serviceWorkers.set(regId, serviceWorker)
    }
    serviceWorker._bookKeeping(updateHow, isVersion)
    if (isAdd) {
      this.emit(Events.WorkerManager.ServiceWorkerAdded, serviceWorker)
    }
  }

  _onAttachedToTarget (event) {
    // console.log(helper.inspect(event, { colors: true }))
    switch (event.targetInfo.type) {
      case 'worker':
        if (!this._workersEnabled) break
        const session = CRIConnection.fromSession(this._client).session(
          event.sessionId
        )
        const worker = new Worker(
          session,
          event.targetInfo.url,
          this._addWorkerConsoleMessage,
          this._handleException
        )
        this._workers.set(event.sessionId, worker)
        this.emit(Events.WorkerManager.WorkerCreated, worker)
        return
      case 'service_worker':
        // TODO evaluation
        // console.log(helper.inspect(event, { colors: true }))
        // this._serviceWorkerTargets.set(
        //   event.targetInfo.targetId,
        //   event.sessionId
        // )
        break
    }
    // service_worker type et al
    this._client
      .send('Target.detachFromTarget', {
        sessionId: event.sessionId
      })
      .catch(debugError)
  }

  /**
   * @param targetId
   * @return {boolean}
   */
  _isActiveServiceWorkerTarget (targetId) {
    return this._serviceWorkerTargets.has(targetId)
  }

  /**
   * @param {string} targetId
   * @return {?CDPSession}
   */
  _workerSession (targetId) {
    const sessionId = this._serviceWorkerTargets.get(targetId)
    if (!sessionId) return null
    return CRIConnection.fromSession(this._client).session(sessionId)
  }

  _onDetachedFromTarget (event) {
    const worker = this._workers.get(event.sessionId)
    if (!worker) return
    this._workers.delete(event.sessionId)
    this.emit(Events.WorkerManager.WorkerDestroyed, worker)
    // TODO sw evaluation
    // if (this._serviceWorkerTargets.has(event.targetId)) {
    //   this._serviceWorkerTargets.delete(event.targetId)
    //   console.log('detached from service worker target')
    //   let regID
    //   for (const sw of this._serviceWorkers.values()) {
    //     if (sw.targetId() === event.targetId) {
    //       regID = sw.registrationId()
    //       break
    //     }
    //   }
    //   if (regID) {
    //     this._removeServiceWorker(regID)
    //   }
    // }
  }

  /**
   * @param {Object} event
   * @param {function(arg: Object):JSHandle} jsHandleFactory
   */
  _addWorkerConsoleMessage (event, jsHandleFactory) {
    const message = new ConsoleMessage(event, { jsHandleFactory })
    this.emit(Events.WorkerManager.Console, message)
  }

  /**
   * @param {!Object} exceptionDetails
   */
  _handleException (exceptionDetails) {
    const message = helper.getExceptionMessage(exceptionDetails)
    const err = new Error(message)
    err.stack = '' // Don't report client-side error with a node stack attached
    this.emit(Events.WorkerManager.Error, err)
  }

  async _enableAutoAttach () {
    if (
      !this._autoAttachEnabled &&
      !this._workersEnabled &&
      !this._serviceWorkersEnabled
    ) {
      return
    }
    this._autoAttachEnabled = true
    await this._client.send('Target.setAutoAttach', {
      autoAttach: true,
      waitForDebuggerOnStart: false,
      flatten: true
    })
  }

  async _disableAutoAttach () {
    if (
      this._autoAttachEnabled &&
      ((this._workersEnabled && !this._serviceWorkersEnabled) ||
        (!this._workersEnabled && this._serviceWorkersEnabled))
    ) {
      return
    }
    this._autoAttachEnabled = false
    await this._client.send('Target.setAutoAttach', {
      autoAttach: false,
      waitForDebuggerOnStart: false,
      flatten: true
    })
  }

  /**
   * @return {{serviceWorkers: Array<ServiceWorker>}}
   */
  toJSON () {
    return { serviceWorkers: this.serviceWorkers(), workers: this.workers() }
  }

  /** @ignore */
  // eslint-disable-next-line space-before-function-paren
  [util.inspect.custom](depth, options) {
    if (depth < 0) {
      return options.stylize(`[ServiceWorkerManager]`, 'special')
    }
    const newOptions = Object.assign({}, options, {
      depth: options.depth == null ? null : options.depth - 1
    })
    const inner = util.inspect(
      {
        serviceWorkersDomainEnabled: this._serviceWorkersEnabled,
        workerMonitoringEnabled: this._workersEnabled,
        workers: this.workers(),
        serviceWorkers: this.serviceWorkers()
      },
      newOptions
    )
    return `${options.stylize('ServiceWorkerManager', 'special')} ${inner}`
  }
}

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