Repository: vuejs/vue
Pull request: feat(ssr): vue-ssr-webpack-plugin compatible with webpack 5

<!--
Please make sure to read the Pull Request Guidelines:
https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#pull-request-guidelines
-->

<!-- PULL REQUEST TEMPLATE -->
<!-- (Update "[ ]" to "[x]" to check a box) -->

**What kind of change does this PR introduce?** (check at least one)

- [ ] Bugfix
- [x] Feature
- [ ] Code style update
- [ ] Refactor
- [ ] Build-related changes
- [ ] Other, please describe:

**Does this PR introduce a breaking change?** (check one)

- [ ] Yes
- [x] No

If yes, please describe the impact and migration path for existing applications:

**The PR fulfills these requirements:**

- [x] It's submitted to the `dev` branch for v2.x (or to a previous version branch), _not_ the `master` branch
- [x] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] All tests are passing: https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#development-setup
- [x] New/updated tests are included

If adding a **new feature**, the PR's description includes:
- [ ] A convincing reason for adding this feature (to avoid wasting your time, it's best to open a suggestion issue first and wait for approval before working on it)

**Other information:**

Similar to #11863, but:

- Fix modules mapping in client plugin
- Ignore hmr assets
- Fix the deprecation warning


Fix #11718
Close #11863 


Changed files:
filename: src/server/webpack-plugin/client.js
patch: @@ -1,6 +1,6 @@
 const hash = require('hash-sum')
 const uniq = require('lodash.uniq')
-import { isJS, isCSS, onEmit } from './util'
+import { isJS, isCSS, getAssetName, onEmit, stripModuleIdHash } from './util'
 
 export default class VueSSRClientPlugin {
   constructor (options = {}) {
@@ -10,7 +10,8 @@ export default class VueSSRClientPlugin {
   }
 
   apply (compiler) {
-    onEmit(compiler, 'vue-client-plugin', (compilation, cb) => {
+    const stage = 'PROCESS_ASSETS_STAGE_ADDITIONAL'
+    onEmit(compiler, 'vue-client-plugin', stage, (compilation, cb) => {
       const stats = compilation.getStats().toJson()
 
       const allFiles = uniq(stats.assets
@@ -19,6 +20,7 @@ export default class VueSSRClientPlugin {
       const initialFiles = uniq(Object.keys(stats.entrypoints)
         .map(name => stats.entrypoints[name].assets)
         .reduce((assets, all) => all.concat(assets), [])
+        .map(getAssetName)
         .filter((file) => isJS(file) || isCSS(file)))
 
       const asyncFiles = allFiles
@@ -34,7 +36,7 @@ export default class VueSSRClientPlugin {
       }
 
       const assetModules = stats.modules.filter(m => m.assets.length)
-      const fileToIndex = file => manifest.all.indexOf(file)
+      const fileToIndex = asset => manifest.all.indexOf(getAssetName(asset))
       stats.modules.forEach(m => {
         // ignore modules duplicated in multiple chunks
         if (m.chunks.length === 1) {
@@ -43,7 +45,7 @@ export default class VueSSRClientPlugin {
           if (!chunk || !chunk.files) {
             return
           }
-          const id = m.identifier.replace(/\s\w+$/, '') // remove appended hash
+          const id = stripModuleIdHash(m.identifier)
           const files = manifest.modules[hash(id)] = chunk.files.map(fileToIndex)
           // find all asset modules associated with the same chunk
           assetModules.forEach(m => {

filename: src/server/webpack-plugin/server.js
patch: @@ -1,4 +1,4 @@
-import { validate, isJS, onEmit } from './util'
+import { validate, isJS, getAssetName, onEmit } from './util'
 
 export default class VueSSRServerPlugin {
   constructor (options = {}) {
@@ -10,7 +10,8 @@ export default class VueSSRServerPlugin {
   apply (compiler) {
     validate(compiler)
 
-    onEmit(compiler, 'vue-server-plugin', (compilation, cb) => {
+    const stage = 'PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER'
+    onEmit(compiler, 'vue-server-plugin', stage, (compilation, cb) => {
       const stats = compilation.getStats().toJson()
       const entryName = Object.keys(stats.entrypoints)[0]
       const entryInfo = stats.entrypoints[entryName]
@@ -20,7 +21,9 @@ export default class VueSSRServerPlugin {
         return cb()
       }
 
-      const entryAssets = entryInfo.assets.filter(isJS)
+      const entryAssets = entryInfo.assets
+        .map(getAssetName)
+        .filter(isJS)
 
       if (entryAssets.length > 1) {
         throw new Error(
@@ -42,14 +45,14 @@ export default class VueSSRServerPlugin {
         maps: {}
       }
 
-      stats.assets.forEach(asset => {
-        if (isJS(asset.name)) {
-          bundle.files[asset.name] = compilation.assets[asset.name].source()
-        } else if (asset.name.match(/\.js\.map$/)) {
-          bundle.maps[asset.name.replace(/\.map$/, '')] = JSON.parse(compilation.assets[asset.name].source())
+      Object.keys(compilation.assets).forEach(name => {
+        if (isJS(name)) {
+          bundle.files[name] = compilation.assets[name].source()
+        } else if (name.match(/\.js\.map$/)) {
+          bundle.maps[name.replace(/\.map$/, '')] = JSON.parse(compilation.assets[name].source())
         }
         // do not emit anything else for server
-        delete compilation.assets[asset.name]
+        delete compilation.assets[name]
       })
 
       const json = JSON.stringify(bundle, null, 2)

filename: src/server/webpack-plugin/util.js
patch: @@ -1,16 +1,27 @@
 const { red, yellow } = require('chalk')
+const webpack = require('webpack')
 
 const prefix = `[vue-server-renderer-webpack-plugin]`
 const warn = exports.warn = msg => console.error(red(`${prefix} ${msg}\n`))
 const tip = exports.tip = msg => console.log(yellow(`${prefix} ${msg}\n`))
 
+const isWebpack5 = !!(webpack.version && webpack.version[0] > 4)
+
 export const validate = compiler => {
   if (compiler.options.target !== 'node') {
     warn('webpack config `target` should be "node".')
   }
 
-  if (compiler.options.output && compiler.options.output.libraryTarget !== 'commonjs2') {
-    warn('webpack config `output.libraryTarget` should be "commonjs2".')
+  if (compiler.options.output) {
+    if (compiler.options.output.library) {
+      // Webpack >= 5.0.0
+      if (compiler.options.output.library.type !== 'commonjs2') {
+        warn('webpack config `output.library.type` should be "commonjs2".')
+      }
+    } else if (compiler.options.output.libraryTarget !== 'commonjs2') {
+      // Webpack < 5.0.0
+      warn('webpack config `output.libraryTarget` should be "commonjs2".')
+    }
   }
 
   if (!compiler.options.externals) {
@@ -21,8 +32,20 @@ export const validate = compiler => {
   }
 }
 
-export const onEmit = (compiler, name, hook) => {
-  if (compiler.hooks) {
+export const onEmit = (compiler, name, stageName, hook) => {
+  if (isWebpack5) {
+    // Webpack >= 5.0.0
+    compiler.hooks.compilation.tap(name, compilation => {
+      if (compilation.compiler !== compiler) {
+        // Ignore child compilers
+        return
+      }
+      const stage = webpack.Compilation[stageName]
+      compilation.hooks.processAssets.tapAsync({ name, stage }, (assets, cb) => {
+        hook(compilation, cb)
+      })
+    })
+  } else if (compiler.hooks) {
     // Webpack >= 4.0.0
     compiler.hooks.emit.tapAsync(name, hook)
   } else {
@@ -31,4 +54,20 @@ export const onEmit = (compiler, name, hook) => {
   }
 }
 
+export const stripModuleIdHash = id => {
+  if (isWebpack5) {
+    // Webpack >= 5.0.0
+    return id.replace(/\|\w+$/, '')
+  }
+  // Webpack < 5.0.0
+  return id.replace(/\s\w+$/, '')
+}
+
+export const getAssetName = asset => {
+  if (typeof asset === 'string') {
+    return asset
+  }
+  return asset.name
+}
+
 export { isJS, isCSS } from '../util'