Vue.js Multi-SPA with Webpack

May 2, 2018
vuejs spa frontend webpack

$ problem

This post is to document how you can configure a vuejs (using vue-cli) project to house two (or more!) different SPAs. As part of my ewserver web application I need to build a frontend. I honestly do not like the javascript ecosystem at all. Too much magic, too many dependencies, but… I figure I should get more experience actually developing with it instead of just hacking around. Like most applications my system has unauthenticated and authenticated areas. However, I don’t want to create two or more projects just to have a login page redirect to the SPAs.

As such, I decided to finally sit down and learn enough of webpack to get this accomplished. There is not a lot of information out there outside for this particular setup, outside of bug tickets anyways.

$ vue-cli setup

Vue-cli has various templates, I decided to go with the webpack template. Getting it setup is pretty straight forward:

$ npm install -g vue-cli
$ vue init webpack my-project
$ cd my-project
$ npm install
$ npm run dev

After installing some other dependencies bootstrap and axios I started thinking about how my login process works. It’s pretty standard; POST to /login with username and password, if JSON response says OK, redirect to /admin. The thing is, I want all my /admin assets to be protected and not included in the unauthenticated parts of the application. I’m a firm believer in reducing attack surface as well as general dependency hygiene, so these components must be separated.

The default template has a directory structure like the following:

├───build
│       build.js
│       check-versions.js
│       logo.png
│       utils.js
│       vue-loader.conf.js
│       webpack.base.conf.js
│       webpack.dev.conf.js
│       webpack.prod.conf.js
│
├───config
│       dev.env.js
│       index.js
│       prod.env.js
│       test.env.js
│
├───src
│   │   App.vue
│   │   main.js
│   │
│   ├───assets
│   │       logo.png
│   │
│   ├───components
│   │       HelloWorld.vue
│   │
│   └───router
│           index.js

Basically src/main.js creates the Vue application, which imports src/App.vue and router/index.js. The App.vue sets up the template and the router redirects / to the HelloWorld component. The HelloWorld.vue component contains the default screen you see when the webpack dev server starts up.

VueJS start up


After searching around, I was able to figure out that webpack supports multiple entry points. In the vuejs webpack template, all the webpack files can be found in the build directory. The base webpack configuration data is in build/webpack.base.conf.js. The module.exports only exposes one entry point:

module.exports = {
  context: path.resolve(__dirname, '../'),
  entry: {
    app: './src/main.js'
  },
  output: {
    path: config.build.assetsRoot,
    filename: '[name].js',
    publicPath: process.env.NODE_ENV === 'production'
      ? config.build.assetsPublicPath
      : config.dev.assetsPublicPath
  },
  ...

I need more than one, where the ‘main’ application will just be the Login component, and the admin page will contain the rest of the application. This turned out to be a bit more complicated than I expected. Mostly because I didn’t really understand how webpack actually worked and the fact that webpack is terrible at displaying errors.

What I ended up wanting was a structure like this:

src
│   App.vue
│   main.js
│
├───assets
│       logo.png
│
├───components
│   │   Hello.vue
│   │   Login.vue
│   │
│   └───admin
│           AdminMenu.vue
│
├───modules
│   └───admin
│       │   admin.js
│       │   AdminApp.vue
│       │   index.html
│       │
│       └───router
│               index.js
│
└───router
        index.js

The src/modules directory will contain separate SPAs. In my case I’ll need an admin section, and eventually a user section. All of these will be separated, but any components that are shared will exist in the components directory. Note that the base application has a router/index.js and the modules/admin directory also has a router directory.

So now I know how I want it to be structured, how the heck do we get webpack to build the assets and allow us to easily test using the webpack dev server?

$ config.mod

First, we need to add multiple entry points. This can be done by modifying the build/webpack.base.conf.js file:

module.exports = {
  entry: {
    app: './src/main.js',
    admin: './src/modules/admin/admin.js'
  },
  output: {
    path: config.build.assetsRoot,
    filename: '[name].js',
    publicPath: process.env.NODE_ENV === 'production'
      ? config.build.assetsPublicPath
      : '/'
  },
  ...

Note that the output.filename: ‘[name].js’ will replace each entry (so one for ‘app’ and one for ‘admin’) when it goes to build the output files. Just add more entries for the other applications or modules you wish to add.

Unfortunately, we need to modify other files as well to get the components injected properly. This is done with the HtmlWebpackPlugin which needs to be added to both the webpack.dev.conf.js and the webpack.prod.conf.js files in the build directory.

Each entry point needs to have its own call to the plugin, so while before we had in webpack.prod.conf.js:

new HtmlWebpackPlugin({
  filename: process.env.NODE_ENV === 'testing'
    ? 'index.html'
    : config.build.index,
  template: 'index.html',
  chunks: ['app'],
  inject: true,
  minify: {
    removeComments: true,
    collapseWhitespace: true,
    removeAttributeQuotes: true
  },
  chunksSortMode: 'dependency'
}),

We now need to add another entry below that:

new HtmlWebpackPlugin({
    filename: 'admin/index.html',
    template: 'src/modules/admin/index.html',
    chunks: ['admin'],
    inject: true,
    minify: {
      removeComments: true,
      collapseWhitespace: true,
      removeAttributeQuotes: true
    },
    chunksSortMode: 'dependency'
  }),

Note that I use ‘src/modules/admin/index.html’ as the admin template, and output it to the admin/index.html file. Also note the chunks: property. This is where you include the entry name of the application or module. Be super careful here, if your chunks name does not exactly match the entry name, it will not build or inject the components. I spent a few hours debugging because webpack never threw an error saying the chunks entry name did not exist!

One other thing we must do is make sure our css/js files that are compiled are separated for the ‘app’ and ‘admin’ entry points. I did this by modifying the webpack.prod.conf.js output properties.

// before:
var webpackConfig = merge(baseWebpackConfig, {
   ...
output: {
    path: config.build.assetsRoot,
    filename: utils.assetsPath('js/[name].[chunkhash].js'),
    chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
  },
...
new ExtractTextPlugin({
  filename: utils.assetsPath('css/[name].[contenthash].css'),
  ...
  allChunks: true,
}),

// after:
var webpackConfig = merge(baseWebpackConfig, {
    ...
output: {
    path: config.build.assetsRoot,
    filename: utils.assetsPath('[name]/js/[name].[chunkhash].js'),
    chunkFilename: utils.assetsPath('[name]/js/[id].[chunkhash].js')
  },
...
new ExtractTextPlugin({
  filename: utils.assetsPath('[name]/css/[name].[contenthash].css')
}),
    ...

Note that in the after section, I added the entry [name] to the beginning of the filename and chunkFilename strings. So js/[name].[chunkhash].js became [name]/js/[name].[chunkhash].js.

Now we can build our components and have it output our bundles:

$ npm run build

// output:
> ewserver@1.0.0 build C:\source\ewserver-ui
> node build/build.js

| building for production...
Starting to optimize CSS...
Processing static/app/css/app.948955d376f12c55f22860949ee1334d.css...
Processing static/admin/css/admin.dbe489e0dab70fba11bf3589dcf431c0.css...
Processed static/app/css/app.948955d376f12c55f22860949ee1334d.css, before: 152587, after: 145146, ratio: 95.12%
Processed static/admin/css/admin.dbe489e0dab70fba11bf3589dcf431c0.css, before: 152528, after: 145085, ratio: 95.12%
Hash: e7573aca244c3e005582
Version: webpack 2.7.0
Time: 10586ms
                                     Asset       Size  Chunks                    Chunk Names
              static/admin/js/admin.js.map    26.9 kB       1  [emitted]         admin
                static/vendor/js/vendor.js     345 kB       0  [emitted]  [big]  vendor
                      static/app/js/app.js    2.99 kB       2  [emitted]         app
            static/manifest/js/manifest.js    1.58 kB       3  [emitted]         manifest
                    static/app/css/app.css     145 kB       2  [emitted]         app
                static/admin/css/admin.css     145 kB       1  [emitted]         admin
            static/vendor/js/vendor.js.map    2.57 MB       0  [emitted]         vendor
                  static/admin/js/admin.js    2.82 kB       1  [emitted]         admin
            static/admin/css/admin.css.map     209 kB       1  [emitted]         admin
                  static/app/js/app.js.map    27.6 kB       2  [emitted]         app
                static/app/css/app.css.map     209 kB       2  [emitted]         app
        static/manifest/js/manifest.js.map    14.9 kB       3  [emitted]         manifest
                                index.html  283 bytes          [emitted]
                          admin/index.html  302 bytes          [emitted]

  Build complete.

  Tip: built files are meant to be served over an HTTP server.
  Opening index.html over file:// won't work.

Looks good! You can see we output the admin files to static/admin/ and app files went to static/app/. Now our files are separated, I can block access to the static/admin/* directories. But the login/vendor code will still be available to unauthenticated users.

$ dev

While it’s great we can build our application, this does not help with testing. For that we need to modify our webpack.dev.conf.js HtmlWebpackPlugin entries as well:

// before:
new HtmlWebpackPlugin({
  filename: 'index.html',
  template: 'index.html',
  inject: true
}),

// after:
new HtmlWebpackPlugin({
    filename: 'index.html',
    chunks: ['app'],
    template: 'index.html',
    inject: true
}),
new HtmlWebpackPlugin({
    filename: 'admin/index.html',
    chunks: ['admin'],
    template: './src/modules/admin/index.html',
    inject: true
}),

Now when we run npm run dev accessing / will inject the App.vue, while accessing admin/index.html will inject/mount the AdminApp.vue.

$ proxy access

One last step I needed was to have the webpack dev server be able to send requests to my API service. At first I tried adding CORS to the API server but quickly gave up. Luckily, webpack has a proxy plugin that makes it rather easy to just shovel off the requests to the real server. In the webpack.dev.conf.js there is a devServer.proxy property. By adding the below, I can have the dev server post to the real server.

proxy: {
  "/login": "http://localhost:8080/",
  "/v1": {
    target: "http://localhost:8080",
    pathRewrite: {"^/v1" : ""}
  }
}

So far it’s working like a charm.

$ end

That’s it, I hope it saves someone some time trying to get multiple SPAs in a single vuejs & webpack project!

comments powered by Disqus