4

This is a bit of an edge case but it would be helpful to know.

When developing an extension using webpack-dev-server to keep the extension code up to date, it would be useful to listen to "webpackHotUpdate"

Chrome extensions with content scripts often have two sides to the equation:

  1. Background
  2. Injected Content Script

When using webpack-dev-server with HMR the background page stays in sync just fine. However content scripts require a reload of the extension in order to reflect the changes. I can remedy this by listening to the "webpackHotUpdate" event from the hotEmmiter and then requesting a reload. At present I have this working in a terrible and very unreliably hacky way.

var hotEmitter = __webpack_require__(XX)

hotEmitter.on('webpackHotUpdate', function() {
    console.log('Reloading Extension')
    chrome.runtime.reload()
})

XX simply represents the number that is currently assigned to the emitter. As you can imagine this changed whenever the build changes so it's a very temporary proof of concept sort of thing.

I suppose I could set up my own socket but that seems like overkill, given the events are already being transferred and I simply want to listen.

I am just recently getting more familiar with the webpack ecosystem so any guidance is much appreciated.

Joel Kelly
  • 177
  • 3
  • 11

3 Answers3

6

Okay!

I worked this out by looking around here:

https://github.com/facebookincubator/create-react-app/blob/master/packages/react-dev-utils/webpackHotDevClient.js

Many thanks to the create-react-app team for their judicious use of comments.

I created a slimmed down version of this specifically for handling the reload condition for extension development.

var SockJS = require('sockjs-client')
var url = require('url')

// Connect to WebpackDevServer via a socket.
var connection = new SockJS(
    url.format({
        // Default values - Updated to your own
        protocol: 'http',
        hostname: 'localhost',
        port: '3000',
        // Hardcoded in WebpackDevServer
        pathname: '/sockjs-node',
    })
)

var isFirstCompilation = true
var mostRecentCompilationHash = null

connection.onmessage = function(e) {
    var message = JSON.parse(e.data)
    switch (message.type) {
        case 'hash':
            handleAvailableHash(message.data)
            break
        case 'still-ok':
        case 'ok':
        case 'content-changed':
            handleSuccess()
            break
        default:
        // Do nothing.
    }
}

// Is there a newer version of this code available?
function isUpdateAvailable() {
    /* globals __webpack_hash__ */
    // __webpack_hash__ is the hash of the current compilation.
    // It's a global variable injected by Webpack.
    return mostRecentCompilationHash !== __webpack_hash__
}

function handleAvailableHash(data){
    mostRecentCompilationHash = data
}

function handleSuccess() {
    var isHotUpdate     = !isFirstCompilation
    isFirstCompilation  = false

    if (isHotUpdate) { handleUpdates() }
}

function handleUpdates() {
    if (!isUpdateAvailable()) return
    console.log('%c Reloading Extension', 'color: #FF00FF')
    chrome.runtime.reload()
}

When you are ready to use it (during development only) you can simply add it to your background.js entry point

module.exports = {
    entry: {
        background: [
            path.resolve(__dirname, 'reloader.js'), 
            path.resolve(__dirname, 'background.js')
        ]
    }
}




For actually hooking into the event emitter as was originally asked you can just require it from webpack/hot/emitter since that file exports an instance of the EventEmitter that's used.
if(module.hot) {
    var lastHash

    var upToDate = function upToDate() {
        return lastHash.indexOf(__webpack_hash__) >= 0
    }

    var clientEmitter = require('webpack/hot/emitter')

    clientEmitter.on('webpackHotUpdate', function(currentHash) {
        lastHash = currentHash
        if(upToDate()) return

        console.log('%c Reloading Extension', 'color: #FF00FF')
        chrome.runtime.reload()
    })
}

This is just a stripped down version straight from the source:

https://github.com/webpack/webpack/blob/master/hot/dev-server.js

Joel Kelly
  • 177
  • 3
  • 11
  • 2
    Sounds cool, I shall try that out. If I understand correctly, the last portion of source code in your answer (starting with if(module.hot) {) is only an alternative solution. Can you share any hints on how you get the extension to run from the webpack dev server in the first place, since it would appear that a bundled js file on localhost makes for an invalid as content script in the manifest.json? – Steve06 Jan 16 '18 at 14:24
  • can anyone confirm the above code works on your side? for me it doesn't work :'( – Hieu Nguyen Trung Apr 24 '18 at 08:22
0

I've fine-tuned the core logic of the crx-hotreload package and come up with a build-tool agnostic solution (meaning it will work with Webpack but also with anything else).

It asks the extension for its directory (via chrome.runtime.getPackageDirectoryEntry) and then watches that directory for file changes. Once a file is added/removed/changed inside that directory, it calls chrome.runtime.reload().

If you'd need to also reload the active tab (when developing a content script), then you should run a tabs.query, get the first (active) tab from the results and call reload on it as well.

The whole logic is ~35 lines of code:

/* global chrome */

const filesInDirectory = dir => new Promise(resolve =>
  dir.createReader().readEntries(entries =>
    Promise.all(entries.filter(e => e.name[0] !== '.').map(e =>
      e.isDirectory
        ? filesInDirectory(e)
        : new Promise(resolve => e.file(resolve))
    ))
      .then(files => [].concat(...files))
      .then(resolve)
  )
)

const timestampForFilesInDirectory = dir => filesInDirectory(dir)
  .then(files => files.map(f => f.name + f.lastModifiedDate).join())

const watchChanges = (dir, lastTimestamp) => {
  timestampForFilesInDirectory(dir).then(timestamp => {
    if (!lastTimestamp || (lastTimestamp === timestamp)) {
      setTimeout(() => watchChanges(dir, timestamp), 1000)
    } else {
      console.log('%c  Reloading Extension', 'color: #FF00FF')
      chrome.runtime.reload()
    }
  })
}

// Init if in dev environment
chrome.management.getSelf(self => {
  if (self.installType === 'development' &&
    'getPackageDirectoryEntry' in chrome.runtime
  ) {
    console.log('%c  Watching for file changes', 'color: #FF00FF')
    chrome.runtime.getPackageDirectoryEntry(dir => watchChanges(dir))
  }
})

You should add this script to your manifest.json file's background scripts entry:

"background": ["reloader.js", "background.js"]

And a Gist with a light explanation in the Readme: https://gist.github.com/andreasvirkus/c9f91ddb201fc78042bf7d814af47121

kano
  • 5,626
  • 3
  • 33
  • 48
0

For someone who wants HMR (not just hot reload) feature for their content script. I've written an article Hot Module Replacement for Chrome Extension and a WebPack plugin crx-load-script-webpack-plugin for it.

The WebPack plugin overrides the loading script mechanism of WebPack. With an appropriate output path, port, and manifest permission configuration. The HMR will work.

// webpack.config.js
    
const CrxLoadScriptWebpackPlugin = require('@cooby/crx-load-script-webpack-plugin');

module.exports = {
  mode: 'development',
  devServer: {
    /**
     * We need devServer write files to disk,
     * But don't want it reload whole page because of the output file changes.
     */
    static: { watch: false },
    /**
     * Set WebSocket url to dev-server, instead of the default `${publicPath}/ws`
     */
    client: {
      webSocketURL: 'ws://localhost:8080/ws',
    },
    /**
     * The host of the page of your script extension runs on.
     * You'll see `[webpack-dev-server] Invalid Host/Origin header` if this is not set.
     */ 
    allowedHosts: ['web.whatsapp.com'],
    devMiddleware: {
      /**
       * Write file to output folder /build, so we can execute it later.
       */
      writeToDisk: true,
    },
  },
  plugins: [
    /** 
     * Enable HMR related plugins. 
     */
    new webpack.HotModuleReplacementPlugin(),
    new CrxLoadScriptWebpackPlugin(),
    new ReactRefreshWebpackPlugin({
      overlay: false,
    }),
  ],
}

manifest.json

{
  "manifest_version": 3,
  "permissions": [
    "scripting"
  ],
  "web_accessible_resources": [
    {
      "resources": [
        "*.hot-update.json",
      ],
      "matches": [
        "https://web.whatsapp.com/*"
      ]
    }
  ],
  "host_permissions": [
    "https://web.whatsapp.com/*"
  ]
}