javascriptdockerwebpackcreate-react-appwebpack-splitchunks

How to handle new version deployement for Docker webpack application that use code splitting?


After deploying a new version of my application in Docker,

I see my console having the following error that break my application:

Uncaught SyntaxError: Unexpected token '<'

error when webpack chunk is missing

In this screenshot, the source that is missing is called: 10.bbfbcd9d.chunk.js, the content of this file looks like:

(this.webpackJsonp=this.webpackJsonp||[]).push([[10],{1062:function(e,t,n){"use strict";var r=n(182);n.d(t,"a",(function(){return r.a}))},1063:function(e,t,n){var ...{source:Z[De],resizeMode:"cover",style:[Y.fixed,{zIndex:-1}]})))}))}}]);
//# sourceMappingURL=10.859374a0.chunk.js.map

This error happens because :

  1. On every release, we build a new Docker image that only include chunks from the latest version
  2. Some clients are running an outdated version and the server won't have a resolution for an old chunk because of (1)

Chunks are .js file that are produced by webpack, see code splitting for more information

Reloading the application will update the version to latest, but it still breaks the app for all users that use an outdated version.

A possible fix I have tried consisted of refreshing the application. If the requested chunk was missing on the server, I was sending a reload signal if the request for a .js file ended up in the wildcard route.

Wild card is serving the index.html of the web application, this for delegating routing to client-side routing in case of an user refreshing it's page

// Handles any requests that don't match the ones above
app.get('*', (req, res) => {
  // prevent old version to download a missing old chunk and force application reload
  if (req.url.slice(-3) === '.js') {
    return res.send(`window.location.reload(true)`);
  }
  return res.sendFile(join(__dirname, '../web-build/index.html'));
});

This appeared to be a bad fix especially on Google Chrome for Android, I have seen my app being refreshed in an infinite loop. (And yes, that is also an ugly fix!)

Since it's not a reliable solution for my end users, I am looking for another way to reload the application if the user client is outdated.

My web application is build using webpack, it's exactly as if it was a create-react-app application, the distributed build directory is containing many .js chunks files.

These are some possible fix I got offered on webpack issue tracker, some were offered by the webpack creator itself:

Related issues

How can I implement a solution that would prevent this error?


Solution

  • If I understood the problem correctly then there are several approaches to this problem and I will list them from the simplest one to more complicated:

    Use previous version to build new version from

    This is by far the simplest approach which only requires to change base image for you new version.

    Consider the following Dockerfile to build version2 of the application:

    FROM version1
    
    RUN ...
    

    Then build it with:

    docker build -t version2 .
    

    This approach, however, has a problem - all old chunks will be accumulating in newer images. It may or may not desirable, but something to take into consideration.

    Another problem is that you can't update you base image easily.

    Use multistage builds

    Multistage builds allow you to run multiple stages and include results from each stage into your final image. Each stage may use different Docker images with different tools, e.g. GCC to compile some native library, but you don't really need GCC in your final image.

    In order to make it work with multi-stage build you would need to be able to create the very first image. Let's consider the following Dockerfile which does exactly that:

    FROM alpine
    
    RUN mkdir -p /app/latest && touch /app/latest/$(cat /proc/sys/kernel/random/uuid).chunk.js
    

    It creates a new new Docker image with a new chunk with random name and put's it into directory named latest - this is important with proposed approach!

    In order to create subsequent versions, we would need a Dockerfile.next which looks like this:

    FROM version2 AS previous
    RUN rm -rf /app/previous && mv /app/latest/ /app/previous
    
    FROM alpine
    
    COPY --from=previous /app /app
    RUN mkdir -p /app/latest && touch /app/latest/$(cat /proc/sys/kernel/random/uuid).chunk.js
    

    In a first stage it rotates version by removing previous version, and moving latest into previous.

    During the second stage, it copies all versions there are left in the first stage, creates a new version and puts it into latest.

    Here's how to use it:

    docker build -t image:1 -f Dockerfile .
    
    >> /app/latest/99cfc0e6-3773-40a0-82d4-8c8643cc243b.chunk.js
    
    docker build -t image:2 --build-arg PREVIOUS_VERSION=1 -f Dockerfile.next .
    
    >> /app/previous/99cfc0e6-3773-40a0-82d4-8c8643cc243b.chunk.js
    >> /app/latest/2adf34c3-c50c-446b-9e85-29fb32011463.chunk.js
    
    docker build -t image:3 --build-arg PREVIOUS_VERSION=2 -f Dockerfile.next 
    
    >> /app/previous/2adf34c3-c50c-446b-9e85-29fb32011463.chunk.js
    >> /app/latest/2e1f8aea-36bb-4b9a-ba48-db88c175cd6b.chunk.js
    
    docker build -t image:4 --build-arg PREVIOUS_VERSION=3 -f Dockerfile.next 
    
    >> /app/previous/2e1f8aea-36bb-4b9a-ba48-db88c175cd6b.chunk.js
    >> /app/latest/851dbbf2-1126-4a44-a734-d5e20ce05d86.chunk.js
    

    Note how chunks are moved from latest to previous.

    This solution requires your server to be able to discover static files in different directories, but that might complicate local development, thought this logic might be conditional based on environment.

    Alternatively you could copy all files into a single directory when container starts. This can be done in ENTRYPOINT script in Docker itself or in your server code - it's completely up to you, depends on what is more convenient.

    Also this example looks at only one version back, but it can be scaled to multiple versions by a more complicated rotation script. For example to keep 3 last versions you could do something like this:

    RUN rm -rf /app/version-0; \
        [ -d /app/version-1 ] && mv /app/version-1 /app/version-0; \
        [ -d /app/version-2 ] && mv /app/version-2 /app/version-1; \
        mv /app/latest /app/version-2; 
    

    Or it can be parameterized using Docker ARG with the number of versions to keep.

    You can read more about multi-stage builds in the official documentation.