CHRIS RAO - DEVELOPER

Bulding an SPA with Flask and Webpack

I tend to divide the front-end and back-end portions of my web apps into separate repos based on the principle that the front-end of a single page app is simply a client of the api, and not a part of the server itself. I think this is a good principle to follow in general. For this project however, in which I was not writing code for production, but simply trying out a new technology, I thought this template would be ideal.

The Pieces

There are two necessary components that go into a single page app:

  1. A bundle of web assets for the front end
  2. The server that serves up the web assets

These are the only two components necessary to build a client-only single-page app. Our app will also include:

  1. An API server

An API server is necessary for things such as secure operations and database connections.

Sometimes, parts 2 and 3 are separate servers, with the web assets simply being served up by a service such as S3. In this case, the API server may serve up a static html page which embeds the externally hosted web assets, or that static page may be kept locally on the user's device as part of a hybrid app.

Other times, the web assets are hosted and served up by the api server. This is what our server will do.

Dependencies

To complete this tutorial, we will be working with.

  1. git
  2. npm
  3. docker
  4. Pipenv

I'm assuming your basic familiarity with these programs going forward.

Basic Setup

Gitlab is a git platform with built in CI as well as a free docker registry. If you don't already have an account, create one now. Then create and clone a repository.

Then, create directories and dockerfiles for your two main components.

mkdir ui api touch ui/Dockerfile api/Dockerfile

The Web Assets

cd into the ui directory.

We're going to build the simplest web asset we can for this template. We'll use webpack to build it along with the terser-webpack-plugin for minification. Create an npm package with three dependencies, webpack, webpack-cli and terser-webpack-plugin. Create your package file by running npm init and filling out the prompts. Then run npm install webpack webpack-cli terser-webpack-plugin --save.

Next, create your webpack file, webpack.config.js

const path = require('path'); const TerserPlugin = require('terser-webpack-plugin'); const webpack = require('webpack'); const distPath = path.join(__dirname, 'dist'); const srcPath = path.join(__dirname, 'src'); module.exports = (env, argv) => { const mode = argv ? argv.mode : 'production'; const config = { entry: ['./src/index.js'], mode, output: { filename: 'bundle.js', path: distPath, }, optimization: { minimizer: [new TerserPlugin({ parallel: true, terserOptions: { ecma: 6, }, })], }, resolve: { extensions: ['*', '.js'], modules: ['node_modules'] }, }; return config; };

With this configuration, webpack will build the source at ./src/index.js into a minified asset. So, go ahead and create ./src/index.js with a simple console log to let us know it's running.

Now that webpack is configured and the source file has been created, add an entry to the script section of your package.config file to run the build:

... "scripts": { "build": "webpack --mode=production" } ...

Now, we'll fill out our Dockerfile to install the project dependencies and build the web bundle:

FROM node:10-stretch WORKDIR /content COPY . /content/ RUN npm ci ENTRYPOINT ["npm"] CMD ["run", "build"]

We also want to add a .dockerignore file to ensure the host machine's node_modules folder isn't copied into the image:

node_modules/

Similarly, create a .gitignore file in the project root directory:

ui/node_modules/*

Containers run from this image will build the javascript asset and then immediately exit. That's fine though. This container's only job is to create an asset we can copy into into the api server image!

The Api Server

Now, cd into your api folder

We're going to build a simple python flask web server that serves up:

  1. The web assets
  2. A static html page that embeds the web assets

We're going to use Pipenv to manage our python version and dependencies. For this tutorial, there will only be a single dependency, flask, but this is simply a template to build your real projects off of. Create a Pipfile with the following contents:

[[source]] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [packages] flask = ">=1.1.2" [requires] python_version = "3.7"

Then run pipenv install. This will generate a Pipenv.lock file that our dockerfile will use to install the pinned dependencies.

The root module for our server will simply be called server. So create a directory called server. Within that directory, create another directory called templates. Flask will know to look in the templates directory for any html templates it needs to render. So let's create a static html file within this directory. Create a file called index.html with the following contents:

<html lang="en"> <head> </head> <body> <script src="/assets/bundle.js"></script> </body> </html>

Our server is going to include a route /assets/<path:path> we'll use for loading our web assets. As you can see, the html template is using this route to load the bundle generated by our ui container. The only other route will be used to return the above static html page. Create a file server.py in the server folder which creates a server with those two routes:

import os import flask def build(): app = flask.Flask(__name__) app.add_url_rule( rule="/assets/<path:path>", methods=["GET"], view_func=assets, provide_automatic_options=True, ) app.add_url_rule( rule="/", methods=["GET"], view_func=landing, defaults={"path": ""}, provide_automatic_options=True, ) app.add_url_rule( rule="/<path:path>", methods=["GET"], view_func=landing, provide_automatic_options=True, ) return app def landing(path): return flask.render_template("landing.html") def assets(path): return flask.send_from_directory('../ui-dist', path)

Okay, I lied. There are actually three routes, but you see, the second and third routes both point to the same view. The only difference is that the second route will match a url with an empty path (i.e. the base url), and the third route will match a url with a path. Both routes simply render the landing page. This template is built for apps with front-end routing, so this is the only html page we will have to worry about rendering.

The first route is the aforementioned assets route, which will load any path prefixed with /assets from the mysterious ui-dist folder. More on that later.

Now we just need an entrypoint to our application. So create a file, wsgi.py in the root api folder:

from server import server if __name__ == '__main__': app = server.build() app.run(host="0.0.0.0", port=5000)

And our server is done! We just need a Dockerfile to turn it into a docker image.

FROM python:3.6-slim RUN pip install 'pipenv>=8.3.0,<8.4.0' COPY ["Pipfile.lock", "/tmp/"] RUN cd /tmp && \ pipenv install --ignore-pipfile --dev --system WORKDIR /content COPY ["wsgi.py", "/content/"] COPY ["server/", "/content/server/"] COPY ["ui-dist/", "/content/ui-dist/"] CMD ["python", "wsgi.py"]

Our Dockerfile installs pipenv, then copies in the Pipfile.lock created by our pipenv environment and then installs the dependencies in Pipfile.lock.

It then copies in our entrypoint file wsgi.py, our server directory and the mysterious ui-dist directory.

Finally, it runs our server with CMD ["python", "wsgi.py"].

Now, this docker image does not yet have all of the pieces it needs to build. It is missing the ui-dist directory.

Tying it all together

We now have all the pieces necessary to produce an image of an api server that hosts it's own client's web assets. We just need to get the compiled ui assets into the build directory for the api server, giving the api server image it's missing piece. Build and run the ui image to build the ui asset, then copy the asset from the stopped ui container into the server's build directory.

docker build --tag=simple-spa-ui-1 ./ui docker run --name=simple-spa-ui-1 simple-spa-ui-1 docker cp simple-spa-ui-1:/content/dist ./api/ui-dist

Now, you can finally build and run your api server, instructing docker to expose port 5000 to outside traffic

docker build --tag=simple-spa-api-1 ./api docker run -p 5000:5000 simple-spa-api-1

The one other thing you'll want to do is prevent ui-dist from being included in your repo. So add a line to .gitignore:

ui/node_modules/* api/ui-dist/*

And you're done! To test it out, navigate to http://localhost:5000 in your browser. If you open up the console, you should see the console log that you added to your javascript source.

Automating the Build

We're going to write scripts to automate builds both on our local machine and as part of our gitlab ci.

For our local machine, create a script, run.sh:

docker rm $(docker ps -aq --filter="name=simple-spa-ui-1") docker build --tag=simple-spa-ui-1 ./ui docker run --name=simple-spa-ui-1 simple-spa-ui-1 docker cp simple-spa-ui-1:/content/dist ./api/ui-dist docker build --tag=simple-spa-api-1 ./api docker run -p 5000:5000 simple-spa-api-1

The extra docker rm command will remove any existing containers with the name simple-spa-ui-1.

Our gitlab pipeline script will be very similar. The only difference is the location we have to copy our ui asset to:

Create the file .gitlab-ci.yml with the following contents:

image: docker:19.03.1 services: - docker:19.03.1-dind variables: IMAGE_ID: ${CI_REGISTRY_IMAGE}/${CI_PROJECT_NAME}-core:${CI_COMMIT_REF_NAME} stages: - build default: before_script: - docker login ${CI_REGISTRY} --username ${CI_REGISTRY_USER} --password ${CI_REGISTRY_PASSWORD} build: stage: build script: - docker build --tag=ui ./ui - docker run --name=ui ui - docker cp ui:/content/dist /builds/$CI_PROJECT_PATH/api/ui-dist - docker build --tag ${IMAGE_ID} ./api - docker push ${IMAGE_ID}

The gitlab runner has to run with the docker-in-docker service in order to build and run the docker images. This service is included in the services section of the file.

Before each script, it also must log onto the docker registry, which it does using the script in the default.before_script section.

The ci has one stage, build. There is one pipeline in the ci, also called build.

The build script includes docker cp ui:/content/dist /builds/$CI_PROJECT_PATH/api/ui-dist to copy the ui asset into the build directory of the api image.

Every build directory is in the /builds directory of the gitlab runner. $CI_PROJECT_PATH is the path of this particular project's build directory.

The last line of the build script pushes the new api server image to it's docker registry.

What's Next?

We've built a project that generates an api server with baked in web assets, but this template is a little clunky to build on top of simply because of the steps involved in bringing up the container. In part 2, we'll go over how to streamline development by upgrading this template with separate dev and prod docker build targets and docker-compose.

See the full working code for this tutorial here: https://gitlab.com/chrisrao/simple-spa-part-1