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:
- A bundle of web assets for the front end
- 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:
- 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.
- git
- npm
- docker
- 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:
- The web assets
- 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