How I integrated Django and Rollup

Django May 6, 2021

Let me preface this by saying that this post is not intended as a complete tutorial, but rather as starting point. Your application structure and deployment strategy are likely different from mine.

Introduction

Why would I even want to integrate Django and Rollup? I find Django's asset strategy to be lacking. In a world where package managers are the norm, vendoring your assets and manually updating them when the time comes seems terribly outdated. This strategy might even mean you open yourself up to potential vulnarabilities as it makes automated updates (for example with Renovate) a great deal more complicated.

Secondly, the modern frontend toolchain offers a lot of goodies to make our lives easier. Tree shaking helps keeping the final bundle size down, ESModules can make our Javascript (or Typescript if you are so inclined) more concise and easier to reason about. While I was hesitent about Tailwind at first, plug and play components can greatly speed up the development process and make the experience more uniform for your users.

Goal

At the end of this, I want to have a modern frontend build toolchain with all the bells and whistles one expects from an application. The frontend should integrate seemlessly into Django. This means to separate deploy process for the frontend, once built the assets should be handed off to Whitenoise.

Any assets included by Django or one of the installed apps, for example the Django admin should work without any fiddling; the frontend tools should be seen as a bonus for the developer, not the only solution.

Setting up Rollup

I used Degit to setup the Rollup starter app to create a base install. Keep in mind that this might overwrite existing files, you have been warned.

npx degit "rollup/rollup-starter-app" . --force

This created a rather undescriptively named directore src, which I renamed to front to better convey the use and contents of this folder to my future self. This means the input argument in the rollup.config.js had to be changed as well to reflect this change. If you forget, Rollup will scream at you.

Tailwind

First install the required dependencies and initialize the Tailwind config.

 npm i -D rollup-plugin-postcss tailwindcss postcss autoprefixer 
 npx tailwindcss init

This will results in the following Tailwind config, which can be edited to your heart's content.

const production = !process.env.ROLLUP_WATCH;

module.exports = {
  purge: {
    content: [
        "project/**/*.html",
        "front/**/*.html",
    ],
    enabled: production
  },
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
}
tailwind.config.js

Key thing to note here if the purge section. This strips all unneeded CSS from the final bundle keeping it nice and small. Without purging, you would likely be serving several MBs worth of CSS to your users, seems a tad execise, does it not? The glob patterns in the content array ensure Django template files are also considered.

Next, the rollup config needs to be edited to load the PostCSS plugin. The PostCSS config file also needs to be created, so that PostCSS "knows" which plugins to load.

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import postcss from "rollup-plugin-postcss";

const path = require("path")

const frontDir = path.join(__dirname, "front")

// `npm run build` -> `production` is true
// `npm run dev` -> `production` is false
const production = !process.env.ROLLUP_WATCH;

export default {
	input: path.join(frontDir, "main.js"),
	output: {
		file: 'dist/bundle.js',
		format: 'iife', // immediately-invoked function expression — suitable for <script> tags
		sourcemap: !production
	},
	plugins: [
		resolve(), // tells Rollup how to find date-fns in node_modules
		commonjs(), // converts date-fns to ES modules
		postcss(), // enable postcss  
		production && terser() // minify, but only in production
	]
};
rollup.config.js
@tailwind base;
@tailwind components;
@tailwind utilities;
front/css/main.css
import './css/main.css'

console.log(`Hello World from Rollup 👋`)
front/main.js

If you would just want to build the files you would now be done. However, watching the files for changes and rebuilding them when needed makes for a much nicer developer experience, enter Rollup's watch API. Nowadays, this is integrated directly into Rollup and can be enabled with a few simple tweaks.

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import postcss from "rollup-plugin-postcss";


const path = require("path")

const frontDir = path.join(__dirname, "front")

// `npm run build` -> `production` is true
// `npm run dev` -> `production` is false
const production = !process.env.ROLLUP_WATCH;

export default {
	input: path.join(frontDir, "main.js"),
	output: {
		file: 'dist/bundle.js',
		format: 'iife', // immediately-invoked function expression — suitable for <script> tags
		sourcemap: !production
	},
	plugins: [
		resolve(), // tells Rollup how to find date-fns in node_modules
		commonjs(), // converts date-fns to ES modules
		postcss({
			extract: true,
		}), // enable postcss
		production && terser() // minify, but only in production
	],
	watch: {
		skipWrite: false,
		include: [
			`${frontDir}/**/*.js`,
			`${path.join(__dirname, "project")}/**/*.html`
		]
	}
};
rollup.config.js

Main part to pay attention to is obviously the watch section and particularly the include array. This should match your setup. In this particular example we are watching JS files in the front directory and HTML files in the project directory, these will be our plain ol' Django templates.

Now, Rollup can watch the files using the following command. Alternatively this could be added as a script in package.json.

rollup -w -c --environment NODE_ENV:development

Informing Django

In the current state Django is completely unaware of the new frontend. This can be easily resolved by adding the dist directory to the STATICFILES_DIRS tuple in settings.py.

Keep in mind that this section of the config assumes an already working asset strategy. In my case I am using Whitenoise, which will take care of compressing the files to GZip and maintaining a manifest with the versions to bust the browser cache. In order this work, Rollup should not be configured to created hash based file names. If you follow the steps outlined above, everything should just work.

import pathlib

APP_DIR = pathlib.Path(__file__).parent
BASE_DIR = APP_DIR.parent

STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STATICFILES_DIRS = (APP_DIR / "core" / "assets", BASE_DIR / "front" / "dist")
settings.py

Lastly, the generated bundles need to be include somewhere in the template chain, most likely in a base.html template of sorts.

<link type="text/css" rel="stylesheet" href="{% static "bundle.css" %}"/>
<script src="{% static "bundle.js" %}"></script>

Docker

Finally, Docker I am using Docker to bundle the bundlers so to speak. I opted for a seperate Dockerfile called Dockerfile.front, as you might have guessed this takes care of all the frontend management.

FROM node:15.14-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

CMD ["npm", "run", "dev"]
Dockerfile.front

For the "main" Dockerfile I am using a multi stage setup where the first stage builds the frontend and the second stage build the resulting (Python) image and copies the bundle(s) from the build stage.

# == [ BUILD ] ==
FROM node:15.14-alpine as builder

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build


# == [ APP ] ==
FROM python:3.9.4
ARG POETRY_ARGS="--no-dev"

ENV PYTHONUNBUFFERED 1
ENV DJANGO_SETTINGS_MODULE meals.settings
ENV PORT 8000

EXPOSE $PORT
WORKDIR /app

RUN apt-get update \
    # dependencies for building Python packages
    && apt-get install -y --no-install-recommends build-essential=12.6 \
    # psycopg2 dependencies
    libpq-dev=11.11-0+deb10u1  \
    # Translations dependencies
    gettext=0.19.8.1-9 \
    # cleaning up unused files
    && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
    && rm -rf /var/lib/apt/lists/*

# Install Poetry
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV PATH /opt/poetry/bin:$PATH
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
 poetry config virtualenvs.create false && \
 poetry config installer.parallel true

# Copy using poetry.lock* in case it doesn't exist yet
COPY pyproject.toml poetry.lock* /app/

# Install dependencies
RUN /opt/poetry/bin/poetry install --no-root $POETRY_ARGS
COPY . /app/
COPY --from=builder /app/dist/ /app/dist/

ENTRYPOINT ["./docker/entrypoint"]
CMD ["./docker/start"]
Dockerfile

In the docker-compose file the npm run dev is run in a seperate container by having the "main" depend on the watch container.

version: "3.8"

networks:
  db:
  mail:

x-app: &app
  

services:
  postgres:
    image: postgres:11.11-alpine  # Should be compatible with prod
    env_file:
      - .env
    networks:
      - db
    ports:
      - 5432:5432
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U $POSTGRES_USER" ]
      interval: 5s
      timeout: 5s
      retries: 5

  frontend:
    build:
      dockerfile: Dockerfile.front
      context: .
    volumes:
      - .:/app

  api:
    build:
      context: .
      args:
        POETRY_ARGS: " "
    restart: unless-stopped
    env_file:
      - .env
    environment:
      - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      - PORT=8000
      - OIDC_RSA_PRIVATE_KEY_FILE=/app/var/oidc.key
    volumes:
      - .:/app
    networks:
      - db
      - mail
    ports:
      - 8000:8000
    depends_on:
      - postgres
      - frontend
docker-compose.yaml

Note the depends_on in the api service.

Conclusion

With this setup it is fairly simple to create and maintain a modern frontend application without being too intrusive. A purely backend / Django developer can do their job without ever knowning what Rollup even is.

As it is basically a normal NPM application, adding extra features such as Typescript, Prettier or any other tool is straightforward as well, the posibilities are endless.

I am curious to know how other have solved this problem though, as I do not believe the entire web is only run by PWAs. So if you have any thoughts to share, I would love to know them.

Tags

Niek

I build web stuff with Python and often Django. Sometimes Vue; when I am brave enough to face the frontend world.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.